Compare commits

..

39 Commits

Author SHA1 Message Date
Min RK
bf73e6f7b7 fix 1.5.0 link in changelog 2021-11-04 13:56:40 +01:00
Min RK
e2631b302a import commands from setuptools
importing build_py from distutils breaks in setuptools 58
2021-11-04 13:55:41 +01:00
Min RK
0d89241c9f release 1.5.0 2021-11-04 13:22:53 +01:00
Min RK
5ac9e7f73a Merge branch 'fix-set-cookie' into 1.4.x
Prepare to release 1.5.0

Fixes GHSA-cw7p-q79f-m2v7
2021-11-04 13:21:52 +01:00
Min RK
9672b534ec changelog for 1.5.0 2021-11-03 16:16:48 +01:00
Min RK
254365716d jupyterlab: don't use $JUPYTERHUB_API_TOKEN in PageConfig.token 2021-11-03 15:52:00 +01:00
Min RK
dcac8c4efe Revert "store tokens passed via url or header, not only url."
This reverts commit 53c3201c17.

Only tokens in URLs should be persisted in cookies.
Tokens in headers should not have any effect on cookies.
2021-11-03 15:52:00 +01:00
Min RK
0611169dea Merge pull request #3677 from minrk/doc-requirements-1x
1.4.x: update doc requirements
2021-11-02 10:38:16 +01:00
Min RK
a432fa3bb6 Merge pull request #3676 from manics/1-use_legacy_stopped_server_status_code
use_legacy_stopped_server_status_code: use 1.* language
2021-11-02 10:35:09 +01:00
Min RK
44141ae025 1.4.x: update doc requirements
pin down docutils, unpin autodoc-traits
2021-11-02 10:35:04 +01:00
Simon Li
04ae25d2c2 use_legacy_stopped_server_status_code: use 1.* language
Also fixes the JupyterHub 2.0 default: will be False not True
2021-11-01 22:14:59 +00:00
Min RK
69a1e97fbe Merge pull request #3639 from yuvipanda/404-1.4.x
Backport #3636 to 1.4.x
2021-10-06 16:08:00 +02:00
YuviPanda
eb0c6514af Set use_legacy_stopped_server_status_code to True for 1.4.x 2021-10-06 17:25:10 +05:30
YuviPanda
d03fc8c531 Update tests that were looking for 503s 2021-10-05 20:19:59 +05:30
YuviPanda
1c8dce533b Preserve older 503 behavior behind a flag 2021-10-05 20:19:59 +05:30
YuviPanda
bbfbc47bb3 Use 424 rather than 404 to indicate non-running server
404 is also used to identify that a particular resource
(like a kernel or terminal) is not present, maybe because
it is deleted. That comes from the notebook server, while
here we are responding from JupyterHub. Saying that the
user server they are trying to request the resource (kernel, etc)
from does not exist seems right.
2021-10-05 20:19:59 +05:30
YuviPanda
be6ec28dab Fail suspected API requests with 404, not 503
Non-running user servers making requests is a fairly
common occurance - user servers get culled while their
browser tabs are left open. So we now have a background level
of 503s responses on the hub *all* the time, making it
very difficult to detect *real* 503s, which should ideally
be closely monitored and alerted on.

I *think* 404 is a more appropriate response, as the resource
(API) being requested is no longer present.
2021-10-05 20:19:59 +05:30
Min RK
bd3a215c9e Merge pull request #3580 from meeseeksmachine/auto-backport-of-pr-3552-on-1.4.x
Backport PR #3552 on branch 1.4.x (Add expiration date dropdown to Token page)
2021-08-23 12:08:56 +02:00
Min RK
3783a1bc6c Backport PR #3552: Add expiration date dropdown to Token page 2021-08-23 09:42:15 +00:00
Min RK
7b0f29b340 Merge pull request #3579 from meeseeksmachine/auto-backport-of-pr-3488-on-1.4.x
Backport PR #3488 on branch 1.4.x (Support auto login when used as a OAuth2 provider)
2021-08-23 10:32:15 +02:00
Min RK
f63e810dfe Backport PR #3488: Support auto login when used as a OAuth2 provider 2021-08-20 08:30:04 +00:00
Min RK
909b3ad4d7 Merge pull request #3538 from consideRatio/pr/release-1.4.2
Release 1.4.2
2021-07-16 10:57:54 +00:00
Erik Sundell
114493be9b release 1.4.2 2021-07-15 16:57:54 +02:00
Erik Sundell
4c0ac5ba91 changelog for 1.4.2 2021-07-15 16:57:52 +02:00
Erik Sundell
52793d65bd Backport PR #3531: Fix regression where external services api_token became required
Issue background

Registering an external service means it won't be run as a process by JupyterHub or similar as I understand it, and such external services may be registered only to get a /services/<service_name> route registered with JupyterHub's configured proxy rather than to actually use an api_token and speak with JupyterHub.

In the past, it was okay for a external service without an api_token to be registered, but not it isn't. This PR fixes that.

The situation when I run into this is when I register grafana as an external service like this (but in reality via a z2jh config with slightly different syntax).

```python
c.JupyterHub.services = [
    {
        "name": "grafana",
        "url": "http://grafana",
    }
]
```

JupyterHub has a [documentation about Services](https://jupyterhub.readthedocs.io/en/stable/reference/services.html properties-of-a-service), where one can see that the default value of api_token is None.

    Issue details

This is an error me and  GeorgianaElena have run into using JupyterHub 1.4.1, but I'm not sure at what point the regression was introduced besides it was around in 1.4.1.

I wrote some notes tracking this issue down. This is the summary I wrote.

```
    This test was made to reproduce an error like this:

        ValueError: Tokens must be at least 8 characters, got ''

    The error had the following stack trace in 1.4.1:

        jupyterhub/app.py:2213: in init_api_tokens
            await self._add_tokens(self.service_tokens, kind='service')
        jupyterhub/app.py:2182: in _add_tokens
            obj.new_api_token(
        jupyterhub/orm.py:424: in new_api_token
            return APIToken.new(token=token, service=self, **kwargs)
        jupyterhub/orm.py:699: in new
            cls.check_token(db, token)

    This test also make _add_tokens receive a token_dict that is buggy:

        {"": "external_2"}

    It turned out that whatever passes token_dict to _add_tokens failed to
    ignore service's api_tokens that were None, and instead passes them as blank
    strings.

    It turned out that init_api_tokens was passing self.service_tokens, and that
    self.service_tokens had been populated with blank string tokens for external
    services registered with JupyterHub.
```

Signed-off-by: Erik Sundell <erik.i.sundell@gmail.com>
2021-07-15 10:16:18 +02:00
passer
320e1924a7 Backport PR #3521: Fix contributor documentation's link
Clicking the contributor documentation's link [https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html) will get an error

This link needs to be replaced with [https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html](https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html)

Signed-off-by: Erik Sundell <erik.i.sundell@gmail.com>
2021-07-15 10:16:16 +02:00
Min RK
2c90715c8d Backport PR #3510: bump autodoc-traits
for sphinx compatibility fix, to get docs building again

Signed-off-by: Erik Sundell <erik.i.sundell@gmail.com>
2021-07-15 10:16:13 +02:00
David Brochart
c99bb32e12 Backport PR #3494: Fix typo
Signed-off-by: Erik Sundell <erik.i.sundell@gmail.com>
2021-07-15 10:16:11 +02:00
Igor Beliakov
fee4ee23c0 Backport PR #3484: Bug: save_bearer_token (provider.py) passes a float value to the expires_at field (int)
**Environment**

* image: k8s-hub (`jupyterhub/k8s-hub:0.11.1`);
* `authenticator_class: dummy`;
* db: cocroachdb (`sqlalchemy-cocroachdb`).

**Description:**

`save_bearer_token` method (`provider.py`) passes a float value to the `expires_at` field (int).

A user can create a notebook, it gets successfully scheduled, and then, once the pod is up and ready, the user is unable to enter the notebook, because jupyterhub cannot save a token. In logs, we can see the following:

```
[I 2021-05-29 14:45:04.302 JupyterHub log:181] 302 GET /hub/api/oauth2/authorize?client_id=jupyterhub-user-user2&redirect_uri=%2Fuser%2Fuser2%2Foauth_callback&response_type=code&state=[secret] -> /user/user2/oauth_callback?code=[secret]&state=[secret] (user2 40.113.125.116) 73.98ms
[E 2021-05-29 14:45:04.424 JupyterHub web:1789] Uncaught exception POST /hub/api/oauth2/token (10.42.80.10)
    HTTPServerRequest(protocol='http', host='hub:8081', method='POST', uri='/hub/api/oauth2/token', version='HTTP/1.1', remote_ip='10.42.80.10')
    Traceback (most recent call last):
      File "/usr/local/lib/python3.8/dist-packages/tornado/web.py", line 1702, in _execute
        result = method(*self.path_args, **self.path_kwargs)
      File "/usr/local/lib/python3.8/dist-packages/jupyterhub/apihandlers/auth.py", line 324, in post
        headers, body, status = self.oauth_provider.create_token_response(
      File "/usr/local/lib/python3.8/dist-packages/oauthlib/oauth2/rfc6749/endpoints/base.py", line 116, in wrapper
        return f(endpoint, uri, *args, **kwargs)
      File "/usr/local/lib/python3.8/dist-packages/oauthlib/oauth2/rfc6749/endpoints/token.py", line 118, in create_token_response
        return grant_type_handler.create_token_response(
      File "/usr/local/lib/python3.8/dist-packages/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py", line 313, in create_token_response
        self.request_validator.save_token(token, request)
      File "/usr/local/lib/python3.8/dist-packages/jupyterhub/oauth/provider.py", line 281, in save_token
        return self.save_bearer_token(token, request, *args, **kwargs)
      File "/usr/local/lib/python3.8/dist-packages/jupyterhub/oauth/provider.py", line 354, in save_bearer_token
        self.db.commit()
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/session.py", line 1042, in commit
        self.transaction.commit()
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/session.py", line 504, in commit
        self._prepare_impl()
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/session.py", line 483, in _prepare_impl
        self.session.flush()
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/session.py", line 2536, in flush
        self._flush(objects)
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/session.py", line 2678, in _flush
        transaction.rollback(_capture_exception=True)
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/util/langhelpers.py", line 68, in __exit__
        compat.raise_(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/util/compat.py", line 182, in raise_
        raise exception
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/session.py", line 2638, in _flush
        flush_context.execute()
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/unitofwork.py", line 422, in execute
        rec.execute(self)
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/unitofwork.py", line 586, in execute
        persistence.save_obj(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/persistence.py", line 239, in save_obj
        _emit_insert_statements(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/orm/persistence.py", line 1135, in _emit_insert_statements
        result = cached_connections[connection].execute(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/engine/base.py", line 1011, in execute
        return meth(self, multiparams, params)
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/sql/elements.py", line 298, in _execute_on_connection
        return connection._execute_clauseelement(self, multiparams, params)
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/engine/base.py", line 1124, in _execute_clauseelement
        ret = self._execute_context(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/engine/base.py", line 1316, in _execute_context
        self._handle_dbapi_exception(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/engine/base.py", line 1510, in _handle_dbapi_exception
        util.raise_(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/util/compat.py", line 182, in raise_
        raise exception
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/engine/base.py", line 1276, in _execute_context
        self.dialect.do_execute(
      File "/usr/local/lib/python3.8/dist-packages/sqlalchemy/engine/default.py", line 593, in do_execute
        cursor.execute(statement, parameters)
    sqlalchemy.exc.ProgrammingError: (psycopg2.errors.DatatypeMismatch) value type decimal doesn't match type int of column "expires_at"
    HINT:  you will need to rewrite or cast the expression

    [SQL: INSERT INTO oauth_access_tokens (client_id, grant_type, expires_at, refresh_token, refresh_expires_at, user_id, session_id, hashed, prefix, created, last_activity) VALUES (%(client_id)s, %(grant_type)s, %(expires_at)s, %(refresh_token)s, %(refresh_expires_at)s, %(user_id)s, %(session_id)s, %(hashed)s, %(prefix)s, %(created)s, %(last_activity)s) RETURNING oauth_access_tokens.id]
    [parameters: {'client_id': 'jupyterhub-user-user2', 'grant_type': 'authorization_code', 'expires_at': 1622303104.418992, 'refresh_token': 'FVJ8S4is0367LlEMnxIiEIoTOeoxhf', 'refresh_expires_at': None, 'user_id': 662636890939424770, 'session_id': '4e041a2bfcb34a34a00033a281bc1236', 'hashed': 'sha512:1:3b18deae37fbf50a:03df035736960af14e19196e1d13fd74f55c21f17405119f80e75817ff37c7567fab089a3d40b97a57f94b54065ee56f7260895352516b9facb989d656f05be8', 'prefix': 't11z', 'created': datetime.datetime(2021, 5, 29, 14, 45, 4, 421305), 'last_activity': None}]
    (Background on this error at: http://sqlalche.me/e/13/f405)

[W 2021-05-29 14:45:04.430 JupyterHub base:110] Rolling back session due to database error (psycopg2.errors.DatatypeMismatch) value type decimal doesn't match type int of column "expires_at"
    HINT:  you will need to rewrite or cast the expression

    [SQL: INSERT INTO oauth_access_tokens (client_id, grant_type, expires_at, refresh_token, refresh_expires_at, user_id, session_id, hashed, prefix, created, last_activity) VALUES (%(client_id)s, %(grant_type)s, %(expires_at)s, %(refresh_token)s, %(refresh_expires_at)s, %(user_id)s, %(session_id)s, %(hashed)s, %(prefix)s, %(created)s, %(last_activity)s) RETURNING oauth_access_tokens.id]
    [parameters: {'client_id': 'jupyterhub-user-user2', 'grant_type': 'authorization_code', 'expires_at': 1622303104.418992, 'refresh_token': 'FVJ8S4is0367LlEMnxIiEIoTOeoxhf', 'refresh_expires_at': None, 'user_id': 662636890939424770, 'session_id': '4e041a2bfcb34a34a00033a281bc1236', 'hashed': 'sha512:1:3b18deae37fbf50a:03df035736960af14e19196e1d13fd74f55c21f17405119f80e75817ff37c7567fab089a3d40b97a57f94b54065ee56f7260895352516b9facb989d656f05be8', 'prefix': 't11z', 'created': datetime.datetime(2021, 5, 29, 14, 45, 4, 421305), 'last_activity': None}]
    (Background on this error at: http://sqlalche.me/e/13/f405)
[E 2021-05-29 14:45:04.443 JupyterHub log:173] {
      "Host": "hub:8081",
      "User-Agent": "python-requests/2.25.1",
      "Accept-Encoding": "gzip, deflate",
      "Accept": "*/*",
      "Connection": "keep-alive",
      "Content-Type": "application/x-www-form-urlencoded",
      "Authorization": "token [secret]",
      "Content-Length": "190"
    }
[E 2021-05-29 14:45:04.444 JupyterHub log:181] 500 POST /hub/api/oauth2/token (user2 10.42.80.10) 63.28ms
```

Everything went well, when I changed:
`expires_at=orm.OAuthAccessToken.now() + token['expires_in'],`
to:
`expires_at=int(orm.OAuthAccessToken.now() + token['expires_in']),`
That's what this PR is about.

As a sidenote, `black` formatter adjusted the `orm_client = orm.OAuthClient(identifier=client_id,)` line, but I guess it should be fine. Please, feel free to revert this change if needed.

(Upd): added the missing `int` conversion.

Signed-off-by: Erik Sundell <erik.i.sundell@gmail.com>
2021-07-15 10:16:08 +02:00
Min RK
2c8b29b6bb Merge pull request #3467 from minrk/1.4.x
Prepare for 1.4.1
2021-05-12 17:16:58 +02:00
Min RK
a53178a92b Backport PR #3462: prepare to rename default branch to main
- update references to default branch name in docs, workflows
- use HEAD in github urls, which always works regardless of default branch name
- fix petstore URLs since the old petstore links seem to have stopped working

to merge, in order:

- [x] approve this PR
- [x] rename the default branch to main in settings
- [x] merge this PR

Related tangent: I've been using [this git default-branch](https://github.com/minrk/git-stuff/blob/main/bin/git-default-branch) to help with my aliases and friends working with repos with different branch names.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:51:06 +02:00
Min RK
e032cda638 release 1.4.1 2021-05-12 15:45:27 +02:00
Min RK
40820b3489 changelog for 1.4.1 2021-05-12 15:42:21 +02:00
Min RK
80f4454371 Backport PR #3457: ci: fix typo in environment variable
When i setup the release workflow i made a typo in an environment variable so signing into Docker Hub now fails.

Observed in https://github.com/jupyterhub/jupyterhub/pull/3456 issuecomment-832923798.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:36:39 +02:00
Erik Sundell
4d0005b0b7 Backport PR #3454: define Spawner.delete_forever on base Spawner
...where I thought it already was! Instead of on the test class.

and fix the logic for when it is called a bit:

- call on *all* Spawners, not just the default
- call on named server deletion when remove=True

closes  3451, finishes  3337

Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:36:36 +02:00
Erik Sundell
86761ff0d4 Backport PR #3456: avoid re-using asyncio.Locks across event loops
should never occur in real applications where only one loop is run, but may occur in tests if the Proxy object lives longer than the loop that is running when it's created (imported?).

I *suspect* this is the source of our intermittent test failures with:

> got Future <Future pending> attached to a different loop

But since they are intermittent, it's hard to be sure, even if this PR passes.

The issue: we were allocating an asyncio.Lock(), which in turn grabs a handle on the current event loop, at *method definition time* in the decorator, instead of *call time*.

The solution: allocate the method at call time *and* double-check to ensure we never use a lock across event loops by storing the locks per-loop.

This should change nothing for 'real' hub instances, where only one loop is ever running, only tests where we start and stop loops a bunch.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:36:34 +02:00
Min RK
32a2a3031c Backport PR #3437: patch base handlers from both jupyter_server and notebook
and clarify warning when a base handler isn't patched that auth is still being applied

- reorganize patch steps into functions for easier re-use
- patch notebook and jupyter_server handlers if they are already imported
- run patch after initialize to ensure extensions have done their importing before we check what's present
- apply class-level patch even when instance-level patch is happening to avoid triggering patch on every request

This change isn't as big as it looks, because it's mostly moving some re-used code to a couple of functions.

closes https://github.com/jupyter-server/jupyter_server/issues/488

Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:36:31 +02:00
Min RK
16352496da Backport PR #3452: Fix documentation
Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:36:28 +02:00
Min RK
2259f57772 Backport PR #3436: ci: github workflow security, pin action to sha etc
Pin references to github actions we rely on in workflows with jobs that reference GitHub secrets that could get exposed.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2021-05-12 15:36:26 +02:00
158 changed files with 2497 additions and 19750 deletions

View File

@@ -5,10 +5,6 @@ name: Release
# but only publish to PyPI on tags
on:
push:
branches:
- "!dependabot/**"
tags:
- "*"
pull_request:
jobs:
@@ -134,7 +130,6 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
@@ -155,7 +150,6 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
@@ -176,7 +170,6 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0

View File

@@ -11,11 +11,46 @@ on:
push:
workflow_dispatch:
defaults:
run:
# Declare bash be used by default in this workflow's "run" steps.
#
# NOTE: bash will by default run with:
# --noprofile: Ignore ~/.profile etc.
# --norc: Ignore ~/.bashrc etc.
# -e: Exit directly on errors
# -o pipefail: Don't mask errors from a command piped into another command
shell: bash
env:
# UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8
jobs:
# Run "pre-commit run --all-files"
pre-commit:
runs-on: ubuntu-20.04
timeout-minutes: 2
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.8
# ref: https://github.com/pre-commit/action
- uses: pre-commit/action@v2.0.0
- name: Help message if pre-commit fail
if: ${{ failure() }}
run: |
echo "You can install pre-commit hooks to automatically run formatting"
echo "on each commit with:"
echo " pre-commit install"
echo "or you can run by hand on staged files with"
echo " pre-commit run"
echo "or after-the-fact on already committed files with"
echo " pre-commit run --all-files"
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04
@@ -38,9 +73,9 @@ jobs:
# Tests everything when JupyterHub works against a dedicated mysql or
# postgresql server.
#
# nbclassic:
# jupyter_server:
# Tests everything when the user instances are started with
# notebook instead of jupyter_server.
# jupyter_server instead of notebook.
#
# ssl:
# Tests everything using internal SSL connections instead of
@@ -48,7 +83,7 @@ jobs:
#
# main_dependencies:
# Tests everything when the we use the latest available dependencies
# from: traitlets.
# from: ipytraitlets.
#
# NOTE: Since only the value of these parameters are presented in the
# GitHub UI when the workflow run, we avoid using true/false as
@@ -56,7 +91,6 @@ jobs:
include:
- python: "3.6"
oldest_dependencies: oldest_dependencies
nbclassic: nbclassic
- python: "3.6"
subdomain: subdomain
- python: "3.7"
@@ -66,7 +100,7 @@ jobs:
- python: "3.8"
db: postgres
- python: "3.8"
nbclassic: nbclassic
jupyter_server: jupyter_server
- python: "3.9"
main_dependencies: main_dependencies
@@ -106,7 +140,6 @@ jobs:
run: |
npm install
npm install -g configurable-http-proxy
npm install -g yarn
npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -131,9 +164,9 @@ jobs:
if [ "${{ matrix.main_dependencies }}" != "" ]; then
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
fi
if [ "${{ matrix.nbclassic }}" != "" ]; then
pip uninstall jupyter_server --yes
pip install notebook
if [ "${{ matrix.jupyter_server }}" != "" ]; then
pip uninstall notebook --yes
pip install jupyter_server
fi
if [ "${{ matrix.db }}" == "mysql" ]; then
pip install mysql-connector-python
@@ -186,16 +219,13 @@ jobs:
# https://github.com/actions/runner/issues/241
run: |
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
- name: Submit codecov report
run: |
codecov
docker-build:
runs-on: ubuntu-20.04
timeout-minutes: 20
timeout-minutes: 10
steps:
- uses: actions/checkout@v2

3
.gitignore vendored
View File

@@ -8,7 +8,6 @@ dist
docs/_build
docs/build
docs/source/_static/rest-api
docs/source/rbac/scope-table.md
.ipynb_checkpoints
# ignore config file at the top-level of the repo
# but not sub-dirs
@@ -30,5 +29,3 @@ htmlcov
pip-wheel-metadata
docs/source/reference/metrics.rst
oldest-requirements.txt
jupyterhub-proxy.pid
examples/server-api/service-token

View File

@@ -1,28 +1,22 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v2.28.0
hooks:
- id: pyupgrade
args:
- --py36-plus
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0
rev: v1.9.0
hooks:
- id: reorder-python-imports
- repo: https://github.com/psf/black
rev: 21.9b0
rev: 20.8b1
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1
rev: v2.2.1
hooks:
- id: prettier
- repo: https://github.com/PyCQA/flake8
rev: "3.9.2"
- repo: https://gitlab.com/pycqa/flake8
rev: "3.8.4"
hooks:
- id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v3.4.0
hooks:
- id: end-of-file-fixer
- id: check-case-conflict

View File

@@ -1,2 +1 @@
share/jupyterhub/templates/
share/jupyterhub/static/js/admin-react.js

View File

@@ -6,37 +6,27 @@
**[License](#license)** |
**[Help and Resources](#help-and-resources)**
---
Please note that this repository is participating in a study into the sustainability of open source projects. Data will be gathered about this repository for approximately the next 12 months, starting from 2021-06-11.
Data collected will include the number of contributors, number of PRs, time taken to close/merge these PRs, and issues closed.
For more information, please visit
[our informational page](https://sustainable-open-science-and-software.github.io/) or download our [participant information sheet](https://sustainable-open-science-and-software.github.io/assets/PIS_sustainable_software.pdf).
---
# [JupyterHub](https://github.com/jupyterhub/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://anaconda.org/conda-forge/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/)
[![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)
[![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/main/graph/badge.svg)](https://codecov.io/gh/jupyterhub/jupyterhub)
[![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/jupyterhub/issues)
[![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub)
[![Gitter](https://img.shields.io/badge/social_chat-gitter-blue?logo=gitter)](https://gitter.im/jupyterhub/jupyterhub)
With [JupyterHub](https://jupyterhub.readthedocs.io) you can create a
**multi-user Hub** that spawns, manages, and proxies multiple instances of the
**multi-user Hub** which spawns, manages, and proxies multiple instances of the
single-user [Jupyter notebook](https://jupyter-notebook.readthedocs.io)
server.
[Project Jupyter](https://jupyter.org) created JupyterHub to support many
users. The Hub can offer notebook servers to a class of students, a corporate
data science workgroup, a scientific research project, or a high-performance
data science workgroup, a scientific research project, or a high performance
computing group.
## Technical overview
@@ -50,9 +40,9 @@ Three main actors make up JupyterHub:
Basic principles for operation are:
- Hub launches a proxy.
- The Proxy forwards all requests to Hub by default.
- Hub handles login and spawns single-user servers on demand.
- Hub configures proxy to forward URL prefixes to the single-user notebook
- Proxy forwards all requests to Hub by default.
- Hub handles login, and spawns single-user servers on demand.
- Hub configures proxy to forward url prefixes to the single-user notebook
servers.
JupyterHub also provides a
@@ -64,14 +54,22 @@ for administration of the Hub and its users.
### Check prerequisites
- A Linux/Unix based system
- [Python](https://www.python.org/downloads/) 3.6 or greater
- [Python](https://www.python.org/downloads/) 3.5 or greater
- [nodejs/npm](https://www.npmjs.com/)
- If you are using **`conda`**, the nodejs and npm dependencies will be installed for
you by conda.
- If you are using **`pip`**, install a recent version (at least 12.0) of
- If you are using **`pip`**, install a recent version of
[nodejs/npm](https://docs.npmjs.com/getting-started/installing-node).
For example, install it on Linux (Debian/Ubuntu) using:
```
sudo apt-get install npm nodejs-legacy
```
The `nodejs-legacy` package installs the `node` executable and is currently
required for npm to work on Debian/Ubuntu.
- If using the default PAM Authenticator, a [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module).
- TLS certificate and key for HTTPS communication
@@ -87,11 +85,12 @@ To install JupyterHub along with its dependencies including nodejs/npm:
conda install -c conda-forge jupyterhub
```
If you plan to run notebook servers locally, install JupyterLab or Jupyter notebook:
If you plan to run notebook servers locally, install the Jupyter notebook
or JupyterLab:
```bash
conda install jupyterlab
conda install notebook
conda install jupyterlab
```
#### Using `pip`
@@ -103,10 +102,10 @@ npm install -g configurable-http-proxy
python3 -m pip install jupyterhub
```
If you plan to run notebook servers locally, you will need to install
[JupyterLab or Jupyter notebook](https://jupyter.readthedocs.io/en/latest/install.html):
If you plan to run notebook servers locally, you will need to install the
[Jupyter notebook](https://jupyter.readthedocs.io/en/latest/install.html)
package:
python3 -m pip install --upgrade jupyterlab
python3 -m pip install --upgrade notebook
### Run the Hub server
@@ -118,7 +117,7 @@ To start the Hub server, run the command:
Visit `https://localhost:8000` in your browser, and sign in with your unix
PAM credentials.
_Note_: To allow multiple users to sign in to the server, you will need to
_Note_: To allow multiple users to sign into the server, you will need to
run the `jupyterhub` command as a _privileged user_, such as root.
The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
describes how to run the server as a _less privileged user_, which requires
@@ -229,12 +228,13 @@ docker container or Linux VM.
We use a shared copyright model that enables all contributors to maintain the
copyright on their contributions.
All code is licensed under the terms of the [revised BSD license](./COPYING.md).
All code is licensed under the terms of the revised BSD license.
## Help and resources
We encourage you to ask questions and share ideas on the [Jupyter community forum](https://discourse.jupyter.org/).
You can also talk with us on our JupyterHub [Gitter](https://gitter.im/jupyterhub/jupyterhub) channel.
We encourage you to ask questions on the [Jupyter mailing list](https://groups.google.com/forum/#!forum/jupyter).
To participate in development discussions or get help, talk with us on
our JupyterHub [Gitter](https://gitter.im/jupyterhub/jupyterhub) channel.
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)

View File

@@ -29,5 +29,5 @@ dependencies = package_json['dependencies']
for dep in dependencies:
src = join(node_modules, dep)
dest = join(components, dep)
print(f"{src} -> {dest}")
print("%s -> %s" % (src, dest))
shutil.copytree(src, dest)

View File

@@ -20,7 +20,7 @@ fi
# Configure a set of databases in the database server for upgrade tests
set -x
for SUFFIX in '' _upgrade_100 _upgrade_122 _upgrade_130; do
for SUFFIX in '' _upgrade_072 _upgrade_081 _upgrade_094; do
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
done

View File

@@ -7,8 +7,8 @@ codecov
coverage
cryptography
html5lib # needed for beautifulsoup
jupyterlab >=3
mock
notebook
pre-commit
pytest>=3.3
pytest-asyncio

View File

@@ -66,12 +66,7 @@ metrics: source/reference/metrics.rst
source/reference/metrics.rst: generate-metrics.py
python3 generate-metrics.py
scopes: source/rbac/scope-table.md
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
python3 source/rbac/generate-scope-table.py
html: rest-api metrics scopes
html: rest-api metrics
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

View File

@@ -1,12 +1,11 @@
-r ../requirements.txt
alabaster_jupyterhub
# Temporary fix of #3021. Revert back to released autodoc-traits when
# 0.1.0 released.
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
myst-parser
autodoc-traits
docutils<0.18
pydata-sphinx-theme
pytablewriter>=0.56
recommonmark>=0.6
sphinx>=1.7
sphinx-copybutton
sphinx-jsonschema

View File

@@ -3,7 +3,7 @@ swagger: "2.0"
info:
title: JupyterHub
description: The REST API for JupyterHub
version: 1.4.0
version: 1.5.0
license:
name: BSD-3-Clause
schemes: [http, https]
@@ -12,62 +12,8 @@ securityDefinitions:
type: apiKey
name: Authorization
in: header
oauth2:
type: oauth2
flow: accessCode
authorizationUrl: "/hub/api/oauth2/authorize" # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations?
tokenUrl: "/hub/api/oauth2/token"
scopes: # Generated based on scope table in jupyterhub/scopes.py
(no_scope): Identify the owner of the requesting entity.
self:
The users own resources _(metascope for users, resolves to (no_scope)
for services)_
all: Everything that the token-owning entity can access _(metascope for tokens)_
admin:users:
Read, write, create and delete users and their authentication state,
not including their servers or tokens.
admin:auth_state: Read a users authentication state.
users:
Read and write permissions to user models (excluding servers, tokens
and authentication state).
read:users:
Read user models (excluding including servers, tokens and authentication
state).
read:users:name: Read names of users.
read:users:groups: Read users group membership.
read:users:activity: Read time of last user activity.
read:roles: Read role assignments.
read:roles:users: Read user role assignments.
read:roles:services: Read service role assignments.
read:roles:groups: Read group role assignments.
users:activity: Update time of last user activity.
admin:servers: Read, start, stop, create and delete user servers and their state.
admin:server_state: Read and write users server state.
servers: Start and stop user servers.
read:servers:
Read users names and their server models (excluding the server
state).
tokens: Read, write, create and delete user tokens.
read:tokens: Read user tokens.
admin:groups: Read and write group information, create and delete groups.
groups:
Read and write group information, including adding/removing users to/from
groups.
read:groups: Read group models.
read:groups:name: Read group names.
read:services: Read service models.
read:services:name: Read service names.
read:hub: Read detailed information about the Hub.
access:servers: Access user servers via API or browser.
access:services: Access services via API or browser.
proxy:
Read information about the proxys routing table, sync the Hub with the
proxy and notify the Hub about a new proxy.
shutdown: Shutdown the hub.
security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope self) or have both (either can be used)?
security:
- token: []
- oauth2:
- self
basePath: /hub/api
produces:
- application/json
@@ -92,9 +38,6 @@ paths:
/info:
get:
summary: Get detailed info about JupyterHub
security:
- oauth2:
- read:hub
description: |
Detailed JupyterHub information, including Python version,
JupyterHub's version and executable path,
@@ -119,9 +62,7 @@ paths:
properties:
class:
type: string
description:
The Python class currently active for JupyterHub
Authentication
description: The Python class currently active for JupyterHub Authentication
version:
type: string
description: The version of the currently active Authenticator
@@ -130,25 +71,13 @@ paths:
properties:
class:
type: string
description:
The Python class currently active for spawning single-user
notebook servers
description: The Python class currently active for spawning single-user notebook servers
version:
type: string
description: The version of the currently active Spawner
/users:
get:
summary: List users
security:
- oauth2:
- read:users
- read:users:name
- read:users:groups
- read:users:activity
- read:servers
- read:roles:users
- admin:auth_state
- admin:server_state
parameters:
- name: state
in: query
@@ -164,22 +93,6 @@ paths:
inactive: all users who have *no* active servers (complement of active)
Added in JupyterHub 1.3
- name: offset
in: query
required: false
type: number
description: |
Return a number users starting at the given offset.
Can be used with limit to paginate.
If unspecified, return all users.
- name: limit
in: query
requred: false
type: number
description: |
Return a finite number of users.
Can be used with offset to paginate.
If unspecified, use api_page_default_limit.
responses:
"200":
description: The Hub's user list
@@ -189,9 +102,6 @@ paths:
$ref: "#/definitions/User"
post:
summary: Create multiple users
security:
- oauth2:
- admin:users
parameters:
- name: body
in: body
@@ -218,16 +128,6 @@ paths:
/users/{name}:
get:
summary: Get a user by name
security:
- oauth2:
- read:users
- read:users:name
- read:users:groups
- read:users:activity
- read:servers
- read:roles:users
- admin:auth_state
- admin:server_state
parameters:
- name: name
description: username
@@ -241,9 +141,6 @@ paths:
$ref: "#/definitions/User"
post:
summary: Create a single user
security:
- oauth2:
- admin:users
parameters:
- name: name
description: username
@@ -258,9 +155,6 @@ paths:
patch:
summary: Modify a user
description: Change a user's name or admin status
security:
- oauth2:
- admin:users
parameters:
- name: name
description: username
@@ -270,22 +164,16 @@ paths:
- name: body
in: body
required: true
description:
Updated user info. At least one key to be updated (name or admin)
is required.
description: Updated user info. At least one key to be updated (name or admin) is required.
schema:
type: object
properties:
name:
type: string
description:
the new name (optional, if another key is updated i.e.
admin)
description: the new name (optional, if another key is updated i.e. admin)
admin:
type: boolean
description:
update admin (optional, if another key is updated i.e.
name)
description: update admin (optional, if another key is updated i.e. name)
responses:
"200":
description: The updated user info
@@ -293,9 +181,6 @@ paths:
$ref: "#/definitions/User"
delete:
summary: Delete a user
security:
- oauth2:
- admin:users
parameters:
- name: name
description: username
@@ -308,12 +193,9 @@ paths:
/users/{name}/activity:
post:
summary: Notify Hub of activity for a given user.
description:
Notify the Hub of activity by the user, e.g. accessing a service
or (more likely) actively using a server.
security:
- oauth2:
- users:activity
description: Notify the Hub of activity by the user,
e.g. accessing a service or (more likely)
actively using a server.
parameters:
- name: name
description: username
@@ -366,9 +248,6 @@ paths:
/users/{name}/server:
post:
summary: Start a user's single-user notebook server
security:
- oauth2:
- servers
parameters:
- name: name
description: username
@@ -392,14 +271,9 @@ paths:
"201":
description: The user's notebook server has started
"202":
description:
The user's notebook server has not yet started, but has been
requested
description: The user's notebook server has not yet started, but has been requested
delete:
summary: Stop a user's server
security:
- oauth2:
- servers
parameters:
- name: name
description: username
@@ -410,15 +284,10 @@ paths:
"204":
description: The user's notebook server has stopped
"202":
description:
The user's notebook server has not yet stopped as it is taking
a while to stop
description: The user's notebook server has not yet stopped as it is taking a while to stop
/users/{name}/servers/{server_name}:
post:
summary: Start a user's single-user named-server notebook server
security:
- oauth2:
- servers
parameters:
- name: name
description: username
@@ -447,14 +316,9 @@ paths:
"201":
description: The user's notebook named-server has started
"202":
description:
The user's notebook named-server has not yet started, but has
been requested
description: The user's notebook named-server has not yet started, but has been requested
delete:
summary: Stop a user's named-server
security:
- oauth2:
- servers
parameters:
- name: name
description: username
@@ -482,9 +346,7 @@ paths:
"204":
description: The user's notebook named-server has stopped
"202":
description:
The user's notebook named-server has not yet stopped as it
is taking a while to stop
description: The user's notebook named-server has not yet stopped as it is taking a while to stop
/users/{name}/tokens:
parameters:
- name: name
@@ -494,9 +356,6 @@ paths:
type: string
get:
summary: List tokens for the user
security:
- oauth2:
- read:tokens
responses:
"200":
description: The list of tokens
@@ -510,9 +369,6 @@ paths:
description: No such user
post:
summary: Create a new token for the user
security:
- oauth2:
- tokens
parameters:
- name: token_params
in: body
@@ -522,17 +378,10 @@ paths:
properties:
expires_in:
type: number
description:
lifetime (in seconds) after which the requested token will
expire.
description: lifetime (in seconds) after which the requested token will expire.
note:
type: string
description: A note attached to the token for future bookkeeping
roles:
type: array
items:
type: string
description: A list of role names that the token should have
responses:
"201":
description: The newly created token
@@ -540,8 +389,6 @@ paths:
$ref: "#/definitions/Token"
"400":
description: Body must be a JSON dict or empty
"403":
description: Requested role does not exist
/users/{name}/tokens/{token_id}:
parameters:
- name: name
@@ -555,9 +402,6 @@ paths:
type: string
get:
summary: Get the model for a token by id
security:
- oauth2:
- read:tokens
responses:
"200":
description: The info for the new token
@@ -565,25 +409,12 @@ paths:
$ref: "#/definitions/Token"
delete:
summary: Delete (revoke) a token by id
security:
- oauth2:
- tokens
responses:
"204":
description: The token has been deleted
/user:
get:
summary: Return authenticated user's model
security:
- oauth2:
- read:users
- read:users:name
- read:users:groups
- read:users:activity
- read:servers
- read:roles:users
- admin:auth_state
- admin:server_state
responses:
"200":
description: The authenticated user's model is returned.
@@ -592,28 +423,6 @@ paths:
/groups:
get:
summary: List groups
security:
- oauth2:
- read:groups
- read:groups:name
- read:roles:groups
parameters:
- name: offset
in: query
required: false
type: number
description: |
Return a number of groups starting at the specified offset.
Can be used with limit to paginate.
If unspecified, return all groups.
- name: limit
in: query
required: false
type: number
description: |
Return a finite number of groups.
Can be used with offset to paginate.
If unspecified, use api_page_default_limit.
responses:
"200":
description: The list of groups
@@ -624,11 +433,6 @@ paths:
/groups/{name}:
get:
summary: Get a group by name
security:
- oauth2:
- read:groups
- read:groups:name
- read:roles:groups
parameters:
- name: name
description: group name
@@ -642,9 +446,6 @@ paths:
$ref: "#/definitions/Group"
post:
summary: Create a group
security:
- oauth2:
- admin:groups
parameters:
- name: name
description: group name
@@ -658,9 +459,6 @@ paths:
$ref: "#/definitions/Group"
delete:
summary: Delete a group
security:
- oauth2:
- admin:groups
parameters:
- name: name
description: group name
@@ -673,9 +471,6 @@ paths:
/groups/{name}/users:
post:
summary: Add users to a group
security:
- oauth2:
- groups
parameters:
- name: name
description: group name
@@ -701,9 +496,6 @@ paths:
$ref: "#/definitions/Group"
delete:
summary: Remove users from a group
security:
- oauth2:
- groups
parameters:
- name: name
description: group name
@@ -728,11 +520,6 @@ paths:
/services:
get:
summary: List services
security:
- oauth2:
- read:services
- read:services:name
- read:roles:services
responses:
"200":
description: The service list
@@ -743,11 +530,6 @@ paths:
/services/{name}:
get:
summary: Get a service by name
security:
- oauth2:
- read:services
- read:services:name
- read:roles:services
parameters:
- name: name
description: service name
@@ -762,58 +544,26 @@ paths:
/proxy:
get:
summary: Get the proxy's routing table
description:
A convenience alias for getting the routing table directly from
the proxy
security:
- oauth2:
- proxy
parameters:
- name: offset
in: query
required: false
type: number
description: |
Return a number of routes starting at the given offset.
Can be used with limit to paginate.
If unspecified, return all routes.
- name: limit
in: query
requred: false
type: number
description: |
Return a finite number of routes.
Can be used with offset to paginate.
If unspecified, use api_page_default_limit
description: A convenience alias for getting the routing table directly from the proxy
responses:
"200":
description: Routing table
schema:
type: object
description:
configurable-http-proxy routing table (see configurable-http-proxy
docs for details)
description: configurable-http-proxy routing table (see configurable-http-proxy docs for details)
post:
summary: Force the Hub to sync with the proxy
security:
- oauth2:
- proxy
responses:
"200":
description: Success
patch:
summary: Notify the Hub about a new proxy
description: Notifies the Hub of a new proxy to use.
security:
- oauth2:
- proxy
parameters:
- name: body
in: body
required: true
description:
Any values that have changed for the new proxy. All keys are
optional.
description: Any values that have changed for the new proxy. All keys are optional.
schema:
type: object
properties:
@@ -841,9 +591,6 @@ paths:
in the JSON request body.
Logging in via this method is only available when the active Authenticator
accepts passwords (e.g. not OAuth).
security:
- oauth2:
- tokens
parameters:
- name: credentials
in: body
@@ -868,9 +615,6 @@ paths:
/authorizations/token/{token}:
get:
summary: Identify a user or service from an API token
security:
- oauth2:
- (noscope)
parameters:
- name: token
in: path
@@ -884,9 +628,7 @@ paths:
/authorizations/cookie/{cookie_name}/{cookie_value}:
get:
summary: Identify a user from a cookie
description:
Used by single-user notebook servers to hand off cookie authentication
to the Hub
description: Used by single-user notebook servers to hand off cookie authentication to the Hub
parameters:
- name: cookie_name
in: path
@@ -903,7 +645,6 @@ paths:
$ref: "#/definitions/User"
"404":
description: A user is not found.
deprecated: true # minrk: lets not add a scope for this, lets remove it
/oauth2/authorize:
get:
summary: "OAuth 2.0 authorize endpoint"
@@ -985,9 +726,6 @@ paths:
/shutdown:
post:
summary: Shutdown the Hub
security:
- oauth2:
- shutdown
parameters:
- name: body
in: body
@@ -996,14 +734,10 @@ paths:
properties:
proxy:
type: boolean
description:
Whether the proxy should be shutdown as well (default from
Hub config)
description: Whether the proxy should be shutdown as well (default from Hub config)
servers:
type: boolean
description:
Whether users' notebook servers should be shutdown as well
(default from Hub config)
description: Whether users' notebook servers should be shutdown as well (default from Hub config)
responses:
"202":
description: Shutdown successful
@@ -1025,11 +759,6 @@ definitions:
admin:
type: boolean
description: Whether the user is an admin
roles:
type: array
description: The names of roles this user has
items:
type: string
groups:
type: array
description: The names of groups where this user is a member
@@ -1051,20 +780,12 @@ definitions:
description: The active servers for this user.
items:
$ref: "#/definitions/Server"
auth_state:
type: string
#TODO: will there be predefined states? Should it rather be object instead of string?
description:
Authentication state of the user. Only available with admin:users:auth_state
scope. None otherwise.
Server:
type: object
properties:
name:
type: string
description:
The server's name. The user's default server has an empty name
('')
description: The server's name. The user's default server has an empty name ('')
ready:
type: boolean
description: |
@@ -1095,15 +816,10 @@ definitions:
description: UTC timestamp last-seen activity on this server.
state:
type: object
description:
Arbitrary internal state from this server's spawner. Only available
on the hub's users list or get-user-by-name method, and only with admin:users:server_state
scope. None otherwise.
description: Arbitrary internal state from this server's spawner. Only available on the hub's users list or get-user-by-name method, and only if a hub admin. None otherwise.
user_options:
type: object
description:
User specified options for the user's spawned instance of a single-user
server.
description: User specified options for the user's spawned instance of a single-user server.
Group:
type: object
properties:
@@ -1115,11 +831,6 @@ definitions:
description: The names of users who are members of this group
items:
type: string
roles:
type: array
description: The names of roles this group has
items:
type: string
Service:
type: object
properties:
@@ -1129,11 +840,6 @@ definitions:
admin:
type: boolean
description: Whether the service is an admin
roles:
type: array
description: The names of roles this service has
items:
type: string
url:
type: string
description: The internal url where the service is running
@@ -1158,9 +864,7 @@ definitions:
properties:
token:
type: string
description:
The token itself. Only present in responses to requests for a
new token.
description: The token itself. Only present in responses to requests for a new token.
id:
type: string
description: The id of the API token. Used for modifying or deleting the token.
@@ -1169,17 +873,10 @@ definitions:
description: The user that owns a token (undefined if owned by a service)
service:
type: string
description: The service that owns the token (undefined of owned by a user)
roles:
type: array
description: The names of roles this token has
items:
type: string
description: The service that owns the token (undefined if owned by a user)
note:
type: string
description:
A note about the token, typically describing what it was created
for.
description: A note about the token, typically describing what it was created for.
created:
type: string
format: date-time

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
#
import os
import sys
@@ -18,20 +19,16 @@ extensions = [
'autodoc_traits',
'sphinx_copybutton',
'sphinx-jsonschema',
'myst_parser',
'recommonmark',
]
myst_enable_extensions = [
'colon_fence',
'deflist',
]
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'JupyterHub'
copyright = '2016, Project Jupyter team'
author = 'Project Jupyter team'
project = u'JupyterHub'
copyright = u'2016, Project Jupyter team'
author = u'Project Jupyter team'
# Autopopulate version
from os.path import dirname
@@ -55,6 +52,11 @@ todo_include_todos = False
# Set the default role so we can use `foo` instead of ``foo``
default_role = 'literal'
# -- Source -------------------------------------------------------------
import recommonmark
from recommonmark.transform import AutoStructify
# -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub
from docutils import nodes
@@ -109,7 +111,9 @@ class HelpAllDirective(SphinxDirective):
def setup(app):
app.add_config_value('recommonmark_config', {'enable_eval_rst': True}, True)
app.add_css_file('custom.css')
app.add_transform(AutoStructify)
app.add_directive('jupyterhub-generate-config', ConfigDirective)
app.add_directive('jupyterhub-help-all', HelpAllDirective)
@@ -146,8 +150,8 @@ latex_documents = [
(
master_doc,
'JupyterHub.tex',
'JupyterHub Documentation',
'Project Jupyter team',
u'JupyterHub Documentation',
u'Project Jupyter team',
'manual',
)
]
@@ -164,7 +168,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [(master_doc, 'jupyterhub', 'JupyterHub Documentation', [author], 1)]
man_pages = [(master_doc, 'jupyterhub', u'JupyterHub Documentation', [author], 1)]
# man_show_urls = False
@@ -178,7 +182,7 @@ texinfo_documents = [
(
master_doc,
'JupyterHub',
'JupyterHub Documentation',
u'JupyterHub Documentation',
author,
'JupyterHub',
'One line description of project.',
@@ -215,7 +219,7 @@ if on_rtd:
# build both metrics and rest-api, since RTD doesn't run make
from subprocess import check_call as sh
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
sh(['make', 'metrics', 'rest-api'], cwd=docs)
# -- Spell checking -------------------------------------------------------

View File

@@ -61,13 +61,6 @@ easy to do with RStudio too.
- [jupyterhub-deploy-teaching](https://github.com/jupyterhub/jupyterhub-deploy-teaching) based on work by Brian Granger for Cal Poly's Data Science 301 Course
### Chameleon
[Chameleon](https://www.chameleoncloud.org) is a NSF-funded configurable experimental environment for large-scale computer science systems research with [bare metal reconfigurability](https://chameleoncloud.readthedocs.io/en/latest/technical/baremetal.html). Chameleon users utilize JupyterHub to document and reproduce their complex CISE and networking experiments.
- [Shared JupyterHub](https://jupyter.chameleoncloud.org): provides a common "workbench" environment for any Chameleon user.
- [Trovi](https://www.chameleoncloud.org/experiment/share): a sharing portal of experiments, tutorials, and examples, which users can launch as a dedicated isolated environments on Chameleon's JupyterHub.
### Clemson University
- Advanced Computing

View File

@@ -18,13 +18,6 @@ started.
## Configure admins (`admin_users`)
```{note}
As of JupyterHub 2.0, the full permissions of `admin_users`
should not be required.
Instead, you can assign [roles][] to users or groups
with only the scopes they require.
```
Admin users of JupyterHub, `admin_users`, can add and remove users from
the user `allowed_users` set. `admin_users` can take actions on other users'
behalf, such as stopping and restarting their servers.

View File

@@ -1,6 +1,6 @@
# Frequently asked questions
## How do I share links to notebooks?
### How do I share links to notebooks?
In short, where you see `/user/name/notebooks/foo.ipynb` use `/hub/user-redirect/notebooks/foo.ipynb` (replace `/user/name` with `/hub/user-redirect`).

View File

@@ -93,40 +93,18 @@ In `jupyterhub_config.py`, add the following dictionary for the
c.JupyterHub.services = [
{
'name': 'idle-culler',
'admin': True,
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600'],
}
]
c.JupyterHub.load_roles = [
{
"name": "list-and-cull", # name the role
"services": [
"idle-culler", # assign the service to this role
],
"scopes": [
# declare what permissions the service should have
"list:users", # list users
"read:users:activity", # read user last-activity
"admin:servers", # start/stop servers
],
}
]
```
where:
- `command` indicates that the Service will be launched as a
- `'admin': True` indicates that the Service has 'admin' permissions, and
- `'command'` indicates that the Service will be launched as a
subprocess, managed by the Hub.
```{versionchanged} 2.0
Prior to 2.0, the idle-culler required 'admin' permissions.
It now needs the scopes:
- `list:users` to access the user list endpoint
- `read:users:activity` to read activity info
- `admin:servers` to start/stop servers
```
## Run `cull-idle` manually as a standalone script
Now you can run your script by providing it
@@ -136,8 +114,7 @@ interact with it.
This will run the idle culler service manually. It can be run as a standalone
script anywhere with access to the Hub, and will periodically check for idle
servers and shut them down via the Hub's REST API. In order to shutdown the
servers, the token given to `cull-idle` must have permission to list users
and admin their servers.
servers, the token given to `cull-idle` must have admin privileges.
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
variable. Run `jupyterhub_idle_culler` manually.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

View File

@@ -108,14 +108,6 @@ API Reference
api/index
RBAC Reference
--------------
.. toctree::
:maxdepth: 2
rbac/index
Contributing
------------

View File

@@ -5,8 +5,8 @@
Before installing JupyterHub, you will need:
- a Linux/Unix based system
- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding
of using [`pip`](https://pip.pypa.io) or
- [Python](https://www.python.org/downloads/) 3.5 or greater. An understanding
of using [`pip`](https://pip.pypa.io/en/stable/) or
[`conda`](https://conda.io/docs/get-started.html) for
installing Python packages is helpful.
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
@@ -20,11 +20,11 @@ Before installing JupyterHub, you will need:
For example, install it on Linux (Debian/Ubuntu) using:
```
sudo apt-get install nodejs npm
sudo apt-get install npm nodejs-legacy
```
[nodesource][] is a great resource to get more recent versions of the nodejs runtime,
if your system package manager only has an old version of Node.js (e.g. 10 or older).
The `nodejs-legacy` package installs the `node` executable and is currently
required for npm to work on Debian/Ubuntu.
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
@@ -33,17 +33,11 @@ Before installing JupyterHub, you will need:
- TLS certificate and key for HTTPS communication
- Domain name
[nodesource]: https://github.com/nodesource/distributions#table-of-contents
Before running the single-user notebook servers (which may be on the same
system as the Hub or not), you will need:
- [JupyterLab][] version 3 or greater,
or [Jupyter Notebook][]
4 or greater.
[jupyterlab]: https://jupyterlab.readthedocs.io
[jupyter notebook]: https://jupyter.readthedocs.io/en/latest/install.html
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
version 4 or greater
## Installation
@@ -54,14 +48,14 @@ JupyterHub can be installed with `pip` (and the proxy with `npm`) or `conda`:
```bash
python3 -m pip install jupyterhub
npm install -g configurable-http-proxy
python3 -m pip install jupyterlab notebook # needed if running the notebook servers in the same environment
python3 -m pip install notebook # needed if running the notebook servers locally
```
**conda** (one command installs jupyterhub and proxy):
```bash
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
conda install jupyterlab notebook # needed if running the notebook servers in the same environment
conda install notebook # needed if running the notebook servers locally
```
Test your installation. If installed, these commands should return the packages'
@@ -80,7 +74,7 @@ To start the Hub server, run the command:
jupyterhub
```
Visit `http://localhost:8000` in your browser, and sign in with your unix
Visit `https://localhost:8000` in your browser, and sign in with your unix
credentials.
To **allow multiple users to sign in** to the Hub server, you must start

View File

@@ -1,126 +0,0 @@
import os
from collections import defaultdict
from pathlib import Path
from pytablewriter import MarkdownTableWriter
from ruamel.yaml import YAML
from jupyterhub.scopes import scope_definitions
HERE = os.path.abspath(os.path.dirname(__file__))
PARENT = Path(HERE).parent.parent.absolute()
class ScopeTableGenerator:
def __init__(self):
self.scopes = scope_definitions
@classmethod
def create_writer(cls, table_name, headers, values):
writer = MarkdownTableWriter()
writer.table_name = table_name
writer.headers = headers
writer.value_matrix = values
writer.margin = 1
return writer
def _get_scope_relationships(self):
"""Returns a tuple of dictionary of all scope-subscope pairs and a list of just subscopes:
({scope: subscope}, [subscopes])
used for creating hierarchical scope table in _parse_scopes()
"""
pairs = []
for scope, data in self.scopes.items():
subscopes = data.get('subscopes')
if subscopes is not None:
for subscope in subscopes:
pairs.append((scope, subscope))
else:
pairs.append((scope, None))
subscopes = [pair[1] for pair in pairs]
pairs_dict = defaultdict(list)
for scope, subscope in pairs:
pairs_dict[scope].append(subscope)
return pairs_dict, subscopes
def _get_top_scopes(self, subscopes):
"""Returns a list of highest level scopes
(not a subscope of any other scopes)"""
top_scopes = []
for scope in self.scopes.keys():
if scope not in subscopes:
top_scopes.append(scope)
return top_scopes
def _parse_scopes(self):
"""Returns a list of table rows where row:
[indented scopename string, scope description string]"""
scope_pairs, subscopes = self._get_scope_relationships()
top_scopes = self._get_top_scopes(subscopes)
table_rows = []
md_indent = "&nbsp;&nbsp;&nbsp;"
def _add_subscopes(table_rows, scopename, depth=0):
description = self.scopes[scopename]['description']
doc_description = self.scopes[scopename].get('doc_description', '')
if doc_description:
description = doc_description
table_row = [f"{md_indent * depth}`{scopename}`", description]
table_rows.append(table_row)
for subscope in scope_pairs[scopename]:
if subscope:
_add_subscopes(table_rows, subscope, depth + 1)
for scope in top_scopes:
_add_subscopes(table_rows, scope)
return table_rows
def write_table(self):
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
filename = f"{HERE}/scope-table.md"
table_name = ""
headers = ["Scope", "Grants permission to:"]
values = self._parse_scopes()
writer = self.create_writer(table_name, headers, values)
title = "Table 1. Available scopes and their hierarchy"
content = f"{title}\n{writer.dumps()}"
with open(filename, 'w') as f:
f.write(content)
print(f"Generated {filename}.")
print(
"Run 'make clean' before 'make html' to ensure the built scopes.html contains latest scope table changes."
)
def write_api(self):
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
filename = f"{PARENT}/rest-api.yml"
yaml = YAML(typ='rt')
yaml.preserve_quotes = True
scope_dict = {}
with open(filename, 'r+') as f:
content = yaml.load(f.read())
f.seek(0)
for scope in self.scopes:
description = self.scopes[scope]['description']
doc_description = self.scopes[scope].get('doc_description', '')
if doc_description:
description = doc_description
scope_dict[scope] = description
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
yaml.dump(content, f)
f.truncate()
def main():
table_generator = ScopeTableGenerator()
table_generator.write_table()
table_generator.write_api()
if __name__ == "__main__":
main()

View File

@@ -1,37 +0,0 @@
# JupyterHub RBAC
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
RBAC is new in JupyterHub 2.0.
## Motivation
The JupyterHub API requires authorization to access its APIs.
This ensures that an arbitrary user, or even an unauthenticated third party, are not allowed to perform such actions.
For instance, the behaviour prior to adoption of RBAC is that creating or deleting users requires _admin rights_.
The prior system is functional, but lacks flexibility. If your Hub serves a number of users in different groups, you might want to delegate permissions to other users or automate certain processes.
Prior to RBAC, appointing a 'group-only admin' or a bot that culls idle servers, requires granting full admin rights to all actions. This poses a risk of the user or service intentionally or unintentionally accessing and modifying any data within the Hub and violates the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege).
To remedy situations like this, JupyterHub is transitioning to an RBAC system. By equipping users, groups and services with _roles_ that supply them with a collection of permissions (_scopes_), administrators are able to fine-tune which parties are granted access to which resources.
## Definitions
**Scopes** are specific permissions used to evaluate API requests. For example: the API endpoint `users/servers`, which enables starting or stopping user servers, is guarded by the scope `servers`.
Scopes are not directly assigned to requesters. Rather, when a client performs an API call, their access will be evaluated based on their assigned roles.
**Roles** are collections of scopes that specify the level of what a client is allowed to do. For example, a group administrator may be granted permission to control the servers of group members, but not to create, modify or delete group members themselves.
Within the RBAC framework, this is achieved by assigning a role to the administrator that covers exactly those privileges.
## Technical Overview
```{toctree}
:maxdepth: 2
roles
scopes
use-cases
tech-implementation
upgrade
```

View File

@@ -1,162 +0,0 @@
(roles)=
# Roles
JupyterHub provides four roles that are available by default:
```{admonition} **Default roles**
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
- `server` role allows for posting activity of "itself" only.
**These roles cannot be deleted.**
```
These default roles have a default collection of scopes,
but you can define the scopes associated with each role (excluding admin) to suit your needs,
as seen [below](overriding-default-roles).
The `user`, `admin`, and `token` roles by default all preserve the permissions prior to RBAC.
Only the `server` role is changed from pre-2.0, to reduce its permissions to activity-only
instead of the default of a full access token.
Additional custom roles can also be defined (see {ref}`define-role-target`).
Roles can be assigned to the following entities:
- Users
- Services
- Groups
- Tokens
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
**Users** \
When a new user gets created, they are assigned their default role `user`. Additionaly, if the user is created with admin privileges (via `c.Authenticator.admin_users` in `jupyterhub_config.py` or `admin: true` via API), they will be also granted `admin` role. If existing user's admin status changes via API or `jupyterhub_config.py`, their default role will be updated accordingly (after next startup for the latter).
**Services** \
Services do not have a default role. Services without roles have no access to the guarded API end-points, so most services will require assignment of a role in order to function.
**Groups** \
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
**Tokens** \
A tokens permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role.
(define-role-target)=
## Defining Roles
Roles can be defined or modified in the configuration file as a list of dictionaries. An example:
% TODO: think about loading users into roles if membership has been changed via API.
% What should be the result?
```python
# in jupyterhub_config.py
c.JupyterHub.load_roles = [
{
'name': 'server-rights',
'description': 'Allows parties to start and stop user servers',
'scopes': ['servers'],
'users': ['alice', 'bob'],
'services': ['idle-culler'],
'groups': ['admin-group'],
}
]
```
The role `server-rights` now allows the starting and stopping of servers by any of the following:
- users `alice` and `bob`
- the service `idle-culler`
- any member of the `admin-group`.
```{attention}
Tokens cannot be assigned roles through role definition but may be assigned specific roles when requested via API (see {ref}`requesting-api-token-target`).
```
Another example:
```python
# in jupyterhub_config.py
c.JupyterHub.load_roles = [
{
'description': 'Read-only user models',
'name': 'reader',
'scopes': ['read:users'],
'services': ['external'],
'users': ['maria', 'joe']
}
]
```
The role `reader` allows users `maria` and `joe` and service `external` to read (but not modify) any users model.
```{admonition} Requirements
:class: warning
In a role definition, the `name` field is required, while all other fields are optional.\
**Role names must:**
- be 3 - 255 characters
- use ascii lowercase, numbers, 'unreserved' URL punctuation `-_.~`
- start with a letter
- end with letter or number.
`users`, `services`, and `groups` only accept objects that already exist in the database or are defined previously in the file.
It is not possible to implicitly add a new user to the database by defining a new role.
```
If no scopes are defined for _new role_, JupyterHub will raise a warning. Providing non-existing scopes will result in an error.
In case the role with a certain name already exists in the database, its definition and scopes will be overwritten. This holds true for all roles except the `admin` role, which cannot be overwritten; an error will be raised if trying to do so. All the role bearers permissions present in the definition will change accordingly.
(overriding-default-roles)=
### Overriding default roles
Role definitions can include those of the "default" roles listed above (admin excluded),
if the default scopes associated with those roles do not suit your deployment.
For example, to specify what permissions the $JUPYTERHUB_API_TOKEN issued to all single-user servers
has,
define the `server` role.
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
use the scope `all`:
```python
c.JupyterHub.load_roles = [
{
'name': 'server',
'scopes': ['all'],
}
]
```
or, better yet, identify the specific [scopes][] you want server environments to have access to.
[scopes]: available-scopes-target
If you don't want to get too detailed,
one option is the `self` scope,
which will have no effect on non-admin users,
but will restrict the token issued to admin user servers to only have access to their own resources,
instead of being able to take actions on behalf of all other users.
```python
c.JupyterHub.load_roles = [
{
'name': 'server',
'scopes': ['self'],
}
]
```
(removing-roles-target)=
## Removing roles
Only the entities present in the role definition in the `jupyterhub_config.py` remain the role bearers. If a user, service or group is removed from the role definition, they will lose the role on the next startup.
Once a role is loaded, it remains in the database until removing it from the `jupyterhub_config.py` and restarting the Hub. All previously defined role bearers will lose the role and associated permissions. Default roles, even if previously redefined through the config file and removed, will not be deleted from the database.

View File

@@ -1,126 +0,0 @@
# Scopes in JupyterHub
A scope has a syntax-based design that reveals which resources it provides access to. Resources are objects with a type, associated data, relationships to other resources, and a set of methods that operate on them (see [RESTful API](https://restful-api-design.readthedocs.io/en/latest/resources.html) documentation for more information).
`<resource>` in the RBAC scope design refers to the resource name in the [JupyterHub's API](../reference/rest-api.rst) endpoints in most cases. For instance, `<resource>` equal to `users` corresponds to JupyterHub's API endpoints beginning with _/users_.
(scope-conventions-target)=
## Scope conventions
- `<resource>` \
The top-level `<resource>` scopes, such as `users` or `groups`, grant read, write, and list permissions to the resource itself as well as its sub-resources. For example, the scope `users:activity` is included in the scope `users`.
- `read:<resource>` \
Limits permissions to read-only operations on single resources.
- `list:<resource>` \
Read-only access to listing endpoints.
Use `read:<resource>:<subresource>` to control what fields are returned.
- `admin:<resource>` \
Grants additional permissions such as create/delete on the corresponding resource in addition to read and write permissions.
- `access:<resource>` \
Grants access permissions to the `<resource>` via API or browser.
- `<resource>:<subresource>` \
The {ref}`vertically filtered <vertical-filtering-target>` scopes provide access to a subset of the information granted by the `<resource>` scope. E.g., the scope `users:activity` only provides permission to post user activity.
- `<resource>!<object>=<objectname>` \
{ref}`horizontal-filtering-target` is implemented by the `!<object>=<objectname>`scope structure. A resource (or sub-resource) can be filtered based on `user`, `server`, `group` or `service` name. For instance, `<resource>!user=charlie` limits access to only return resources of user `charlie`. \
Only one filter per scope is allowed, but filters for the same scope have an additive effect; a larger filter can be used by supplying the scope multiple times with different filters.
By adding a scope to an existing role, all role bearers will gain the associated permissions.
## Metascopes
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
1. default user scope `self`, and
2. default token scope `all`.
(default-user-scope-target)=
### Default user scope
Access to the user's own resources and subresources is covered by metascope `self`. This metascope includes the user's model, activity, servers and tokens. For example, `self` for a user named "gerard" includes:
- `users!user=gerard` where the `users` scope provides access to the full user model and activity. The filter restricts this access to the user's own resources.
- `servers!user=gerard` which grants the user access to their own servers without being able to create/delete any.
- `tokens!user=gerard` which allows the user to access, request and delete their own tokens.
- `access:servers!user=gerard` which allows the user to access their own servers via API or browser.
The `self` scope is only valid for user entities. In other cases (e.g., for services) it resolves to an empty set of scopes.
(default-token-scope-target)=
### Default token scope
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
(horizontal-filtering-target)=
## Horizontal filtering
Horizontal filtering, also called _resource filtering_, is the concept of reducing the payload of an API call to cover only the subset of the _resources_ that the scopes of the client provides them access to.
Requested resources are filtered based on the filter of the corresponding scope. For instance, if a service requests a user list (guarded with scope `read:users`) with a role that only contains scopes `read:users!user=hannah` and `read:users!user=ivan`, the returned list of user models will be an intersection of all users and the collection `{hannah, ivan}`. In case this intersection is empty, the API call returns an HTTP 404 error, regardless if any users exist outside of the clients scope filter collection.
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
### `!user` filter
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
The filter can be applied to any scope.
(vertical-filtering-target)=
## Vertical filtering
Vertical filtering, also called _attribute filtering_, is the concept of reducing the payload of an API call to cover only the _attributes_ of the resources that the scopes of the client provides them access to. This occurs when the client scopes are subscopes of the API endpoint that is called.
For instance, if a client requests a user list with the only scope being `read:users:groups`, the returned list of user models will contain only a list of groups per user.
In case the client has multiple subscopes, the call returns the union of the data the client has access to.
The payload of an API call can be filtered both horizontally and vertically simultaneously. For instance, performing an API call to the endpoint `/users/` with the scope `users:name!user=juliette` returns a payload of `[{name: 'juliette'}]` (provided that this name is present in the database).
(available-scopes-target)=
## Available scopes
Table below lists all available scopes and illustrates their hierarchy. Indented scopes indicate subscopes of the scope(s) above them.
There are four exceptions to the general {ref}`scope conventions <scope-conventions-target>`:
- `read:users:name` is a subscope of both `read:users` and `read:servers`. \
The `read:servers` scope requires access to the user name (server owner) due to named servers distinguished internally in the form `!server=username/servername`.
- `read:users:activity` is a subscope of both `read:users` and `users:activity`. \
Posting activity via the `users:activity`, which is not included in `users` scope, needs to check the last valid activity of the user.
- `read:roles:users` is a subscope of both `read:roles` and `admin:users`. \
Admin privileges to the _users_ resource include the information about user roles.
- `read:roles:groups` is a subscope of both `read:roles` and `admin:groups`. \
Similar to the `read:roles:users` above.
```{include} scope-table.md
```
```{Caution}
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
```
### Scopes and APIs
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
Listed scopes by each API endpoint reflect the "lowest" permissions required to gain any access to the corresponding API. For example, posting user's activity (_POST /users/:name/activity_) needs `users:activity` scope. If scope `users` is passed during the request, the access will be granted as the required scope is a subscope of the `users` scope. If, on the other hand, `read:users:activity` scope is passed, the access will be denied.

View File

@@ -1,80 +0,0 @@
# Technical Implementation
Roles are stored in the database, where they are associated with users, services, etc., and can be added or modified as explained in {ref}`define-role-target` section. Users, services, groups, and tokens can gain, change, and lose roles. This is currently achieved via `jupyterhub_config.py` (see {ref}`define-role-target`) and will be made available via API in future. The latter will allow for changing a token's role, and thereby its permissions, without the need to issue a new token.
Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. Scope variables take on five different formats which is reflected throughout the utilities via specific nomenclature:
```{admonition} **Scope variable nomenclature**
:class: tip
- _scopes_ \
List of scopes with abbreviations (used in role definitions). E.g., `["users:activity!user"]`.
- _expanded scopes_ \
Set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
- _parsed scopes_ \
Dictionary JSON like format of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
- _intersection_ \
Set of expanded scopes as intersection of 2 expanded scope sets.
- _identify scopes_ \
Set of expanded scopes needed for identify (whoami) endpoints.
```
(resolving-roles-scopes-target)=
## Resolving roles and scopes
**Resolving roles** refers to determining which roles a user, service, token, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific roles or making an API request. The following sections provide more details.
(requesting-api-token-target)=
### Requesting API token with specific roles
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific existing roles. To date, it is only possible to add roles to a token through the _POST /users/:name/tokens_ API where the roles can be specified in the token parameters body (see [](../reference/rest-api.rst)).
RBAC adds several steps into the token issue flow.
If no roles are requested, the token is issued with the default `token` role (providing the requester is allowed to create the token).
If the token is requested with any roles, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed. The API request is resolved without additional errors using the scopes _intersection_, but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
Resolving a token's roles (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error.
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
```{figure} ../images/rbac-token-request-chart.png
:align: center
:name: token-request-chart
Figure 1. Resolving roles and scopes during API token request
```
### Making an API request
With the RBAC framework each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
When an API request is performed, the requesting API token's roles are again resolved (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
The passed scopes are compared to the scopes required to access the API as follows:
- if the API scopes are present within the set of passed scopes, the access is granted and the API returns its "full" response
- if that is not the case, another check is utilized to determine if subscopes of the required API scopes can be found in the passed scope set:
- if found, the RBAC framework employs the {ref}`filtering <vertical-filtering-target>` procedures to refine the API response to access only resource attributes corresponding to the passed scopes. For example, providing a scope `read:users:activity!group=class-C` for the _GET /users_ API will return a list of user models from group `class-C` containing only the `last_activity` attribute for each user model
- if not found, the access to API is denied
{ref}`Figure 2 <api-request-chart>` illustrates this process highlighting the steps where the role and scope resolutions as well as filtering occur in orange.
```{figure} ../images/rbac-api-request-chart.png
:align: center
:name: api-request-chart
Figure 2. Resolving roles and scopes when an API request is made
```

View File

@@ -1,54 +0,0 @@
# Upgrading JupyterHub with RBAC framework
RBAC framework requires different database setup than any previous JupyterHub versions due to eliminating the distinction between OAuth and API tokens (see {ref}`oauth-vs-api-tokens-target` for more details). This requires merging the previously two different database tables into one. By doing so, all existing tokens created before the upgrade no longer comply with the new database version and must be replaced.
This is achieved by the Hub deleting all existing tokens during the database upgrade and recreating the tokens loaded via the `jupyterhub_config.py` file with updated structure. However, any manually issued or stored tokens are not recreated automatically and must be manually re-issued after the upgrade.
No other database records are affected.
(rbac-upgrade-steps-target)=
## Upgrade steps
1. All running **servers must be stopped** before proceeding with the upgrade.
2. To upgrade the Hub, follow the [Upgrading JupyterHub](../admin/upgrading.rst) instructions.
```{attention}
We advise against defining any new roles in the `jupyterhub.config.py` file right after the upgrade is completed and JupyterHub restarted for the first time. This preserves the 'current' state of the Hub. You can define and assign new roles on any other following startup.
```
3. After restarting the Hub **re-issue all tokens that were previously issued manually** (i.e., not through the `jupyterhub_config.py` file).
When the JupyterHub is restarted for the first time after the upgrade, all users, services and tokens stored in the database or re-loaded through the configuration file will be assigned their default role. Any newly added entities after that will be assigned their default role only if no other specific role is requested for them.
## Changing the permissions after the upgrade
Once all the {ref}`upgrade steps <rbac-upgrade-steps-target>` above are completed, the RBAC framework will be available for utilization. You can define new roles, modify default roles (apart from `admin`) and assign them to entities as described in the {ref}`define-role-target` section.
We recommended the following procedure to start with RBAC:
1. Identify which admin users and services you would like to grant only the permissions they need through the new roles.
2. Strip these users and services of their admin status via API or UI. This will change their roles from `admin` to `user`.
```{note}
Stripping entities of their roles is currently available only via `jupyterhub_config.py` (see {ref}`removing-roles-target`).
```
3. Define new roles that you would like to start using with appropriate scopes and assign them to these entities in `jupyterhub_config.py`.
4. Restart the JupyterHub for the new roles to take effect.
(oauth-vs-api-tokens-target)=
## OAuth vs API tokens
### Before RBAC
Previous JupyterHub versions utilize two types of tokens, OAuth token and API token.
OAuth token is issued by the Hub to a single-user server when the user logs in. The token is stored in the browser cookie and is used to identify the user who owns the server during the OAuth flow. This token by default expires when the cookie reaches its expiry time of 2 weeks (or after 1 hour in JupyterHub versions < 1.3.0).
API token is issued by the Hub to a single-user server when launched and is used to communicate with the Hub's APIs such as posting activity or completing the OAuth flow. This token has no expiry by default.
API tokens can also be issued to users via API ([_/hub/token_](../reference/urls.md) or [_POST /users/:username/tokens_](../reference/rest-api.rst)) and services via `jupyterhub_config.py` to perform API requests.
### With RBAC
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.

View File

@@ -1,130 +0,0 @@
# Use Cases
To determine which scopes a role should have, one can follow these steps:
1. Determine what actions the role holder should have/have not access to
2. Match the actions against the [JupyterHub's APIs](../reference/rest-api.rst)
3. Check which scopes are required to access the APIs
4. Combine scopes and subscopes if applicable
5. Customize the scopes with filters if needed
6. Define the role with required scopes and assign to users/services/groups/tokens
Below, different use cases are presented on how to use the RBAC framework.
## Service to cull idle servers
Finding and shutting down idle servers can save a lot of computational resources.
We can make use of [jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler) to manage this for us.
Below follows a short tutorial on how to add a cull-idle service in the RBAC system.
1. Install the cull-idle server script with `pip install jupyterhub-idle-culler`.
2. Define a new service `idle-culler` and a new role for this service:
```python
# in jupyterhub_config.py
c.JupyterHub.services = [
{
"name": "idle-culler",
"command": [
sys.executable, "-m",
"jupyterhub_idle_culler",
"--timeout=3600"
],
}
]
c.JupyterHub.load_roles = [
{
"name": "idle-culler",
"description": "Culls idle servers",
"scopes": ["read:users:name", "read:users:activity", "servers"],
"services": ["idle-culler"],
}
]
```
```{important}
Note that in the RBAC system the `admin` field in the `idle-culler` service definition is omitted. Instead, the `idle-culler` role provides the service with only the permissions it needs.
If the optional actions of deleting the idle servers and/or removing inactive users are desired, **change the following scopes** in the `idle-culler` role definition:
- `servers` to `admin:servers` for deleting servers
- `read:users:name`, `read:users:activity` to `admin:users` for deleting users.
```
3. Restart JupyterHub to complete the process.
## API launcher
A service capable of creating/removing users and launching multiple servers should have access to:
1. _POST_ and _DELETE /users_
2. _POST_ and _DELETE /users/:name/server_ or _/users/:name/servers/:server_name_
3. Creating/deleting servers
The scopes required to access the API enpoints:
1. `admin:users`
2. `servers`
3. `admin:servers`
From the above, the role definition is:
```python
# in jupyterhub_config.py
c.JupyterHub.load_roles = [
{
"name": "api-launcher",
"description": "Manages servers",
"scopes": ["admin:users", "admin:servers"],
"services": [<service_name>]
}
]
```
If needed, the scopes can be modified to limit the permissions to e.g. a particular group with `!group=groupname` filter.
## Group admin roles
Roles can be used to specify different group member privileges.
For example, a group of students `class-A` may have a role allowing all group members to access information about their group. Teacher `johan`, who is a student of `class-A` but a teacher of another group of students `class-B`, can have additional role permitting him to access information about `class-B` students as well as start/stop their servers.
The roles can then be defined as follows:
```python
# in jupyterhub_config.py
c.JupyterHub.load_groups = {
'class-A': ['johan', 'student1', 'student2'],
'class-B': ['student3', 'student4']
}
c.JupyterHub.load_roles = [
{
'name': 'class-A-student',
'description': 'Grants access to information about the group',
'scopes': ['read:groups!group=class-A'],
'groups': ['class-A']
},
{
'name': 'class-B-student',
'description': 'Grants access to information about the group',
'scopes': ['read:groups!group=class-B'],
'groups': ['class-B']
},
{
'name': 'teacher',
'description': 'Allows for accessing information about teacher group members and starting/stopping their servers',
'scopes': [ 'read:users!group=class-B', 'servers!group=class-B'],
'users': ['johan']
}
]
```
In the above example, `johan` has privileges inherited from `class-A-student` role and the `teacher` role on top of those.
```{note}
The scope filters (`!group=`) limit the privileges only to the particular groups. `johan` can access the servers and information of `class-B` group members only.
```

View File

@@ -37,7 +37,7 @@ with any provider, is also available.
## The Dummy Authenticator
When testing, it may be helpful to use the
{class}`jupyterhub.auth.DummyAuthenticator`. This allows for any username and
:class:`~jupyterhub.auth.DummyAuthenticator`. This allows for any username and
password unless if a global password has been set. Once set, any username will
still be accepted but the correct password will need to be provided.

View File

@@ -76,26 +76,13 @@ c.InteractiveShellApp.extensions.append("cython")
### Example: Enable a Jupyter notebook configuration setting for all users
:::{note}
These examples configure the Jupyter ServerApp,
which is used by JupyterLab, the default in JupyterHub 2.0.
If you are using the classing Jupyter Notebook server,
the same things should work,
with the following substitutions:
- Where you see `jupyter_server_config`, use `jupyter_notebook_config`
- Where you see `NotebookApp`, use `ServerApp`
:::
To enable Jupyter notebook's internal idle-shutdown behavior (requires
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_server_config.py`
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_notebook_config.py`
file:
```python
# shutdown the server after no activity for an hour
c.ServerApp.shutdown_no_activity_timeout = 60 * 60
c.NotebookApp.shutdown_no_activity_timeout = 60 * 60
# shutdown kernels after no activity for 20 minutes
c.MappingKernelManager.cull_idle_timeout = 20 * 60
# check for idle kernels every two minutes
@@ -125,8 +112,8 @@ Assuming I have a Python 2 and Python 3 environment that I want to make
sure are available, I can install their specs system-wide (in /usr/local) with:
```bash
/path/to/python3 -m ipykernel install --prefix=/usr/local
/path/to/python2 -m ipykernel install --prefix=/usr/local
/path/to/python3 -m IPython kernel install --prefix=/usr/local
/path/to/python2 -m IPython kernel install --prefix=/usr/local
```
## Multi-user hosts vs. Containers
@@ -189,40 +176,12 @@ The number of named servers per user can be limited by setting
c.JupyterHub.named_server_limit_per_user = 5
```
(classic-notebook-ui)=
## Switching to Jupyter Server
## Switching back to classic notebook
[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 server launches JupyterLab,
which is based on [Jupyter Server][].
This is the default server when running JupyterHub ≥ 2.0.
You can switch to using the legacy Jupyter Notebook server by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable
(in the single-user environment) to:
```bash
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
```
[jupyter server]: https://jupyter-server.readthedocs.io
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
:::{versionchanged} 2.0
JupyterLab is now the default singleuser UI, if available,
which is based on the [Jupyter Server][],
no longer the legacy [Jupyter Notebook][] server.
JupyterHub prior to 2.0 launched the legacy notebook server (`jupyter notebook`),
and Jupyter server could be selected by specifying
```python
# jupyterhub_config.py
c.Spawner.cmd = ["jupyter-labhub"]
```
or for an otherwise customized Jupyter Server app,
set the environment variable:
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

@@ -16,7 +16,6 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
proxy
separate-proxy
rest
server-api
monitoring
database
templates
@@ -27,4 +26,3 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
config-proxy
config-sudo
config-reference
oauth

View File

@@ -1,373 +0,0 @@
# JupyterHub and OAuth
JupyterHub uses OAuth 2 internally as a mechanism for authenticating users.
As such, JupyterHub itself always functions as an OAuth **provider**.
More on what that means [below](oauth-terms).
Additionally, JupyterHub is _often_ deployed with [oauthenticator](https://oauthenticator.readthedocs.io),
where an external identity provider, such as GitHub or KeyCloak, is used to authenticate users.
When this is the case, there are _two_ nested oauth flows:
an _internal_ oauth flow where JupyterHub is the **provider**,
and and _external_ oauth flow, where JupyterHub is a **client**.
This means that when you are using JupyterHub, there is always _at least one_ and often two layers of OAuth involved in a user logging in and accessing their server.
Some relevant points:
- Single-user servers _never_ need to communicate with or be aware of the upstream provider configured in your Authenticator.
As far as they are concerned, only JupyterHub is an OAuth provider,
and how users authenticate with the Hub itself is irrelevant.
- When talking to a single-user server,
there are ~always two tokens:
a token issued to the server itself to communicate with the Hub API,
and a second per-user token in the browser to represent the completed login process and authorized permissions.
More on this [later](two-tokens).
(oauth-terms)=
## Key OAuth terms
Here are some key definitions to keep in mind when we are talking about OAuth.
You can also read more detail [here](https://www.oauth.com/oauth2-servers/definitions/).
- **provider** the entity responsible for managing identity and authorization,
always a web server.
JupyterHub is _always_ an oauth provider for JupyterHub's components.
When OAuthenticator is used, an external service, such as GitHub or KeyCloak, is also an oauth provider.
- **client** An entity that requests OAuth **tokens** on a user's behalf,
generally a web server of some kind.
OAuth **clients** are services that _delegate_ authentication and/or authorization
to an OAuth **provider**.
JupyterHub _services_ or single-user _servers_ are OAuth **clients** of the JupyterHub **provider**.
When OAuthenticator is used, JupyterHub is itself _also_ an OAuth **client** for the external oauth **provider**, e.g. GitHub.
- **browser** A user's web browser, which makes requests and stores things like cookies
- **token** The secret value used to represent a user's authorization. This is the final product of the OAuth process.
- **code** A short-lived temporary secret that the **client** exchanges
for a **token** at the conclusion of oauth,
in what's generally called the "oauth callback handler."
## One oauth flow
OAuth **flow** is what we call the sequence of HTTP requests involved in authenticating a user and issuing a token, ultimately used for authorized access to a service or single-user server.
A single oauth flow generally goes like this:
### OAuth request and redirect
1. A **browser** makes an HTTP request to an oauth **client**.
2. There are no credentials, so the client _redirects_ the browser to an "authorize" page on the oauth **provider** with some extra information:
- the oauth **client id** of the client itself
- the **redirect uri** to be redirected back to after completion
- the **scopes** requested, which the user should be presented with to confirm.
This is the "X would like to be able to Y on your behalf. Allow this?" page you see on all the "Login with ..." pages around the Internet.
3. During this authorize step,
the browser must be _authenticated_ with the provider.
This is often already stored in a cookie,
but if not the provider webapp must begin its _own_ authentication process before serving the authorization page.
This _may_ even begin another oauth flow!
4. After the user tells the provider that they want to proceed with the authorization,
the provider records this authorization in a short-lived record called an **oauth code**.
5. Finally, the oauth provider redirects the browser _back_ to the oauth client's "redirect uri"
(or "oauth callback uri"),
with the oauth code in a url parameter.
That's the end of the requests made between the **browser** and the **provider**.
### State after redirect
At this point:
- The browser is authenticated with the _provider_
- The user's authorized permissions are recorded in an _oauth code_
- The _provider_ knows that the given oauth client's requested permissions have been granted, but the client doesn't know this yet.
- All requests so far have been made directly by the browser.
No requests have originated at the client or provider.
### OAuth Client Handles Callback Request
Now we get to finish the OAuth process.
Let's dig into what the oauth client does when it handles
the oauth callback request with the
- The OAuth client receives the _code_ and makes an API request to the _provider_ to exchange the code for a real _token_.
This is the first direct request between the OAuth _client_ and the _provider_.
- Once the token is retrieved, the client _usually_
makes a second API request to the _provider_
to retrieve information about the owner of the token (the user).
This is the step where behavior diverges for different OAuth providers.
Up to this point, all oauth providers are the same, following the oauth specification.
However, oauth does not define a standard for exchanging tokens for information about their owner or permissions ([OpenID Connect](https://openid.net/connect/) does that),
so this step may be different for each OAuth provider.
- Finally, the oauth client stores its own record that the user is authorized in a cookie.
This could be the token itself, or any other appropriate representation of successful authentication.
- Last of all, now that credentials have been established,
the browser can be redirected to the _original_ URL where it started,
to try the request again.
If the client wasn't able to keep track of the original URL all this time
(not always easy!),
you might end up back at a default landing page instead of where you started the login process. This is frustrating!
😮‍💨 _phew_.
So that's _one_ OAuth process.
## Full sequence of OAuth in JupyterHub
Let's go through the above oauth process in JupyterHub,
with specific examples of each HTTP request and what information is contained.
For bonus points, we are using the double-oauth example of JupyterHub configured with GitHubOAuthenticator.
To disambiguate, we will call the OAuth process where JupyterHub is the **provider** "internal oauth,"
and the one with JupyterHub as a **client** "external oauth."
Our starting point:
- a user's single-user server is running. Let's call them `danez`
- jupyterhub is running with GitHub as an oauth provider (this means two full instances of oauth),
- Danez has a fresh browser session with no cookies yet
First request:
- browser->single-user server running JupyterLab or Jupyter Classic
- `GET /user/danez/notebooks/mynotebook.ipynb`
- no credentials, so single-user server (as an oauth **client**) starts internal oauth process with JupyterHub (the **provider**)
- response: 302 redirect -> `/hub/api/oauth2/authorize`
with:
- client-id=`jupyterhub-user-danez`
- redirect-uri=`/user/danez/oauth_callback` (we'll come back later!)
Second request, following redirect:
- browser->jupyterhub
- `GET /hub/api/oauth2/authorize`
- no credentials, so jupyterhub starts external oauth process _with GitHub_
- response: 302 redirect -> `https://github.com/login/oauth/authorize`
with:
- client-id=`jupyterhub-client-uuid`
- redirect-uri=`/hub/oauth_callback` (we'll come back later!)
_pause_ This is where JupyterHub configuration comes into play.
Recall, in this case JupyterHub is using:
```python
c.JupyterHub.authenticator_class = 'github'
```
That means authenticating a request to the Hub itself starts
a _second_, external oauth process with GitHub as a provider.
This external oauth process is optional, though.
If you were using the default username+password PAMAuthenticator,
this redirect would have been to `/hub/login` instead, to present the user
with a login form.
Third request, following redirect:
- browser->GitHub
- `GET https://github.com/login/oauth/authorize`
Here, GitHub prompts for login and asks for confirmation of authorization
(more redirects if you aren't logged in to GitHub yet, but ultimately back to this `/authorize` URL).
After successful authorization
(either by looking up a pre-existing authorization,
or recording it via form submission)
GitHub issues an **oauth code** and redirects to `/hub/oauth_callback?code=github-code`
Next request:
- browser->JupyterHub
- `GET /hub/oauth_callback?code=github-code`
Inside the callback handler, JupyterHub makes two API requests:
The first:
- JupyterHub->GitHub
- `POST https://github.com/login/oauth/access_token`
- request made with oauth **code** from url parameter
- response includes an access **token**
The second:
- JupyterHub->GitHub
- `GET https://api.github.com/user`
- request made with access **token** in the `Authorization` header
- response is the user model, including username, email, etc.
Now the external oauth callback request completes with:
- set cookie on `/hub/` path, recording jupyterhub authentication so we don't need to do external oauth with GitHub again for a while
- redirect -> `/hub/api/oauth2/authorize`
🎉 At this point, we have completed our first OAuth flow! 🎉
Now, we get our first repeated request:
- browser->jupyterhub
- `GET /hub/api/oauth2/authorize`
- this time with credentials,
so jupyterhub either
1. serves the internal authorization confirmation page, or
2. automatically accepts authorization (shortcut taken when a user is visiting their own server)
- redirect -> `/user/danez/oauth_callback?code=jupyterhub-code`
Here, we start the same oauth callback process as before, but at Danez's single-user server for the _internal_ oauth
- browser->single-user server
- `GET /user/danez/oauth_callback`
(in handler)
Inside the internal oauth callback handler,
Danez's server makes two API requests to JupyterHub:
The first:
- single-user server->JupyterHub
- `POST /hub/api/oauth2/token`
- request made with oauth code from url parameter
- response includes an API token
The second:
- single-user server->JupyterHub
- `GET /hub/api/user`
- request made with token in the `Authorization` header
- response is the user model, including username, groups, etc.
Finally completing `GET /user/danez/oauth_callback`:
- response sets cookie, storing encrypted access token
- _finally_ redirects back to the original `/user/danez/notebooks/mynotebook.ipynb`
Final request:
- browser -> single-user server
- `GET /user/danez/notebooks/mynotebook.ipynb`
- encrypted jupyterhub token in cookie
To authenticate this request, the single token stored in the encrypted cookie is passed to the Hub for verification:
- single-user server -> Hub
- `GET /hub/api/user`
- browser's token in Authorization header
- response: user model with name, groups, etc.
If the user model matches who should be allowed (e.g. Danez),
then the request is allowed.
See {doc}`../rbac/scopes` for how JupyterHub uses scopes to determine authorized access to servers and services.
_the end_
## Token caches and expiry
Because tokens represent information from an external source,
they can become 'stale,'
or the information they represent may no longer be accurate.
For example: a user's GitHub account may no longer be authorized to use JupyterHub,
that should ultimately propagate to revoking access and force logging in again.
To handle this, OAuth tokens and the various places they are stored can _expire_,
which should have the same effect as no credentials,
and trigger the authorization process again.
In JupyterHub's internal oauth, we have these layers of information that can go stale:
- The oauth client has a **cache** of Hub responses for tokens,
so it doesn't need to make API requests to the Hub for every request it receives.
This cache has an expiry of five minutes by default,
and is governed by the configuration `HubAuth.cache_max_age` in the single-user server.
- The internal oauth token is stored in a cookie, which has its own expiry (default: 14 days),
governed by `JupyterHub.cookie_max_age_days`.
- The internal oauth token can also itself expire,
which is by default the same as the cookie expiry,
since it makes sense for the token itself and the place it is stored to expire at the same time.
This is governed by `JupyterHub.cookie_max_age_days` first,
or can overridden by `JupyterHub.oauth_token_expires_in`.
That's all for _internal_ auth storage,
but the information from the _external_ authentication provider
(could be PAM or GitHub OAuth, etc.) can also expire.
Authenticator configuration governs when JupyterHub needs to ask again,
triggering the external login process anew before letting a user proceed.
- `jupyterhub-hub-login` cookie stores that a browser is authenticated with the Hub.
This expires according to `JupyterHub.cookie_max_age_days` configuration,
with a default of 14 days.
The `jupyterhub-hub-login` cookie is encrypted with `JupyterHub.cookie_secret`
configuration.
- {meth}`.Authenticator.refresh_user` is a method to refresh a user's auth info.
By default, it does nothing, but it can return an updated user model if a user's information has changed,
or force a full login process again if needed.
- {attr}`.Authenticator.auth_refresh_age` configuration governs how often
`refresh_user()` will be called to check if a user must login again (default: 300 seconds).
- {attr}`.Authenticator.refresh_pre_spawn` configuration governs whether
`refresh_user()` should be called prior to spawning a server,
to force fresh auth info when a server is launched (default: False).
This can be useful when Authenticators pass access tokens to spawner environments, to ensure they aren't getting a stale token that's about to expire.
**So what happens when these things expire or get stale?**
- If the HubAuth **token response cache** expires,
when a request is made with a token,
the Hub is asked for the latest information about the token.
This usually has no visible effect, since it is just refreshing a cache.
If it turns out that the token itself has expired or been revoked,
the request will be denied.
- If the token has expired, but is still in the cookie:
when the token response cache expires,
the next time the server asks the hub about the token,
no user will be identified and the internal oauth process begins again.
- If the token _cookie_ expires, the next browser request will be made with no credentials,
and the internal oauth process will begin again.
This will usually have the form of a transparent redirect browsers won't notice.
However, if this occurs on an API request in a long-lived page visit
such as a JupyterLab session, the API request may fail and require
a page refresh to get renewed credentials.
- If the _JupyterHub_ cookie expires, the next time the browser makes a request to the Hub,
the Hub's authorization process must begin again (e.g. login with GitHub).
Hub cookie expiry on its own **does not** mean that a user can no longer access their single-user server!
- If credentials from the upstream provider (e.g. GitHub) become stale or outdated,
these will not be refreshed until/unless `refresh_user` is called
_and_ `refresh_user()` on the given Authenticator is implemented to perform such a check.
At this point, few Authenticators implement `refresh_user` to support this feature.
If your Authenticator does not or cannot implement `refresh_user`,
the only way to force a check is to reset the `JupyterHub.cookie_secret` encryption key,
which invalidates the `jupyterhub-hub-login` cookie for all users.
### Logging out
Logging out of JupyterHub means clearing and revoking many of these credentials:
- The `jupyterhub-hub-login` cookie is revoked, meaning the next request to the Hub itself will require a new login.
- The token stored in the `jupyterhub-user-username` cookie for the single-user server
will be revoked, based on its associaton with `jupyterhub-session-id`, but the _cookie itself cannot be cleared at this point_
- The shared `jupyterhub-session-id` is cleared, which ensures that the HubAuth **token response cache** will not be used,
and the next request with the expired token will ask the Hub, which will inform the single-user server that the token has expired
## Extra bits
(two-tokens)=
### A tale of two tokens
**TODO**: discuss API token issued to server at startup ($JUPYTERHUB_API_TOKEN)
and oauth-issued token in the cookie,
and some details of how JupyterLab currently deals with that.
They are different, and JupyterLab should be making requests using the token from the cookie,
not the token from the server,
but that is not currently the case.
### Redirect loops
In general, an authenticated web endpoint has this behavior,
based on the authentication/authorization state of the browser:
- If authorized, allow the request to happen
- If authenticated (I know who you are) but not authorized (you are not allowed), fail with a 403 permission denied error
- If not authenticated, start a redirect process to establish authorization,
which should end in a redirect back to the original URL to try again.
**This is why problems in authentication result in redirect loops!**
If the second request fails to detect the authentication that should have been established during the redirect,
it will start the authentication redirect process over again,
and keep redirecting in a loop until the browser balks.

View File

@@ -220,11 +220,3 @@ previously required.
Additionally, configurable attributes for your proxy will
appear in jupyterhub help output and auto-generated configuration files
via `jupyterhub --generate-config`.
### Index of proxies
A list of the proxies that are currently available for JupyterHub (that we know about).
1. [`jupyterhub/configurable-http-proxy`](https://github.com/jupyterhub/configurable-http-proxy) The default proxy which uses node-http-proxy
2. [`jupyterhub/traefik-proxy`](https://github.com/jupyterhub/traefik-proxy) The proxy which configures traefik proxy server for jupyterhub
3. [`AbdealiJK/configurable-http-proxy`](https://github.com/AbdealiJK/configurable-http-proxy) A pure python implementation of the configurable-http-proxy

View File

@@ -17,7 +17,6 @@ such as:
- adding or removing users
- stopping or starting single user notebook servers
- authenticating services
- communicating with an individual Jupyter server's REST API
A [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)
API provides a standard way for users to get and send information to the
@@ -28,7 +27,8 @@ Hub.
To send requests using JupyterHub API, you must pass an API token with
the request.
The preferred way of generating an API token is:
As of [version 0.6.0](../changelog.md), the preferred way of
generating an API token is:
```bash
openssl rand -hex 32
@@ -48,34 +48,33 @@ jupyterhub token <username>
This command generates a random string to use as a token and registers
it for the given user with the Hub's database.
In [version 0.8.0](../changelog.md), a token request page for
In [version 0.8.0](../changelog.md), a TOKEN request page for
generating an API token is available from the JupyterHub user interface:
![Request API token page](../images/token-request.png)
![Request API TOKEN page](../images/token-request.png)
![API token success page](../images/token-request-success.png)
![API TOKEN success page](../images/token-request-success.png)
## Assigning permissions to a token
## Add API tokens to the config file
Prior to JupyterHub 2.0, there were two levels of permissions:
**This is deprecated. We are in no rush to remove this feature,
but please consider if service tokens are right for you.**
1. user, and
2. admin
You may also add a dictionary of API tokens and usernames to the hub's
configuration file, `jupyterhub_config.py` (note that
the **key** is the 'secret-token' while the **value** is the 'username'):
where a token would always have full permissions to do whatever its owner could do.
In JupyterHub 2.0,
specific permissions are now defined as 'scopes',
and can be assigned both at the user/service level,
and at the individual token level.
This allows e.g. a user with full admin permissions to request a token with limited permissions.
```python
c.JupyterHub.api_tokens = {
'secret-token': 'username',
}
```
### Updating to admin services
The `api_tokens` configuration has been softly deprecated since the introduction of services.
We have no plans to remove it,
but deployments are encouraged to use service configuration instead.
but users are encouraged to use service configuration instead.
If you have been using `api_tokens` to create an admin user
and a token for that user to perform some automations,
@@ -89,40 +88,19 @@ c.JupyterHub.api_tokens = {
}
```
This can be updated to create a service, with the following configuration:
This can be updated to create an admin service, with the following configuration:
```python
c.JupyterHub.services = [
{
# give the token a name
"name": "service-admin",
"name": "service-token",
"admin": True,
"api_token": "secret-token",
# "admin": True, # if using JupyterHub 1.x
},
]
# roles are new in JupyterHub 2.0
# prior to 2.0, only 'admin': True or False
# was available
c.JupyterHub.load_roles = [
{
"name": "service-role",
"scopes": [
# specify the permissions the token should have
"admin:users",
"admin:services",
],
"services": [
# assign the service the above permissions
"service-admin",
],
}
]
```
The token will have the permissions listed in the role
(see [scopes][] for a list of available permissions),
The token will have the same admin permissions,
but there will no longer be a user account created to house it.
The main noticeable difference is that there will be no notebook server associated with the account
and the service will not show up in the various user list pages and APIs.
@@ -134,7 +112,7 @@ Authorization header.
### Use requests
Using the popular Python [requests](https://docs.python-requests.org)
Using the popular Python [requests](http://docs.python-requests.org/en/master/)
library, here's example code to make an API request for the users of a JupyterHub
deployment. An API GET request is made, and the request sends an API token for
authorization. The response contains information about the users:
@@ -146,9 +124,9 @@ api_url = 'http://127.0.0.1:8081/hub/api'
r = requests.get(api_url + '/users',
headers={
'Authorization': f'token {token}',
}
)
'Authorization': 'token %s' % token,
}
)
r.raise_for_status()
users = r.json()
@@ -166,95 +144,19 @@ data = {'name': 'mygroup', 'users': ['user1', 'user2']}
r = requests.post(api_url + '/groups/formgrade-data301/users',
headers={
'Authorization': f'token {token}',
},
json=data,
'Authorization': 'token %s' % token,
},
json=data
)
r.raise_for_status()
r.json()
```
The same API token can also authorize access to the [Jupyter Notebook REST API][]
provided by notebook servers managed by JupyterHub if it has the necessary `access:users:servers` scope:
provided by notebook servers managed by JupyterHub if one of the following is true:
(api-pagination)=
## Paginating API requests
```{versionadded} 2.0
```
Pagination is available through the `offset` and `limit` query parameters on
list endpoints, which can be used to return ideally sized windows of results.
Here's example code demonstrating pagination on the `GET /users`
endpoint to fetch the first 20 records.
```python
import os
import requests
api_url = 'http://127.0.0.1:8081/hub/api'
r = requests.get(
api_url + '/users?offset=0&limit=20',
headers={
"Accept": "application/jupyterhub-pagination+json",
"Authorization": f"token {token}",
},
)
r.raise_for_status()
r.json()
```
For backward-compatibility, the default structure of list responses is unchanged.
However, this lacks pagination information (e.g. is there a next page),
so if you have enough users that they won't fit in the first response,
it is a good idea to opt-in to the new paginated list format.
There is a new schema for list responses which include pagination information.
You can request this by including the header:
```
Accept: application/jupyterhub-pagination+json
```
with your request, in which case a response will look like:
```python
{
"items": [
{
"name": "username",
"kind": "user",
...
},
],
"_pagination": {
"offset": 0,
"limit": 20,
"total": 50,
"next": {
"offset": 20,
"limit": 20,
"url": "http://127.0.0.1:8081/hub/api/users?limit=20&offset=20"
}
}
}
```
where the list results (same as pre-2.0) will be in `items`,
and pagination info will be in `_pagination`.
The `next` field will include the offset, limit, and URL for requesting the next page.
`next` will be `null` if there is no next page.
Pagination is governed by two configuration options:
- `JupyterHub.api_page_default_limit` - the page size, if `limit` is unspecified in the request
and the new pagination API is requested
(default: 50)
- `JupyterHub.api_page_max_limit` - the maximum page size a request can ask for (default: 200)
Pagination is enabled on the `GET /users`, `GET /groups`, and `GET /proxy` REST endpoints.
1. The token is for the same user as the owner of the notebook
2. The token is tied to an admin user or service **and** `c.JupyterHub.admin_access` is set to `True`
## Enabling users to spawn multiple named-servers via the API

View File

@@ -1,369 +0,0 @@
# Starting servers with the JupyterHub API
JupyterHub's [REST API][] allows launching servers on behalf of users
without ever interacting with the JupyterHub UI.
This allows you to build services launching Jupyter-based services for users
without relying on the JupyterHub UI at all,
enabling a variety of user/launch/lifecycle patterns not natively supported by JupyterHub,
without needing to develop all the server management features of JupyterHub Spawners and/or Authenticators.
[BinderHub][] is an example of such an application.
[binderhub]: https://binderhub.readthedocs.io
[rest api]: ../reference/rest.md
This document provides an example of working with the JupyterHub API to
manage servers for users.
In particular, we will cover how to:
1. [check status of servers](checking)
2. [start servers](starting)
3. [wait for servers to be ready](waiting)
4. [communicate with servers](communicating)
5. [stop servers](stopping)
(checking)=
## Checking server status
Requesting information about a user includes a `servers` field,
which is a dictionary.
```
GET /hub/api/users/:username
```
**Required scope: `read:servers`**
```json
{
"admin": false,
"groups": [],
"pending": null,
"server": null,
"name": "test-1",
"kind": "user",
"last_activity": "2021-08-03T18:12:46.026411Z",
"created": "2021-08-03T18:09:59.767600Z",
"roles": ["user"],
"servers": {}
}
```
If the `servers` dict is empty, the user has no running servers.
The keys of the `servers` dict are server names as strings.
Many JupyterHub deployments only use the 'default' server,
which has the empty string `''` for a name.
In this case, the servers dict will always have either zero or one elements.
This is the servers dict when the user's default server is fully running and ready:
```json
"servers": {
"": {
"name": "",
"last_activity": "2021-08-03T18:48:35.934000Z",
"started": "2021-08-03T18:48:29.093885Z",
"pending": null,
"ready": true,
"url": "/user/test-1/",
"user_options": {},
"progress_url": "/hub/api/users/test-1/server/progress"
}
}
```
Key properties of a server:
name
: the server's name. Always the same as the key in `servers`
ready
: boolean. If true, the server can be expected to respond to requests at `url`.
pending
: `null` or a string indicating a transitional state (such as `start` or `stop`).
Will always be `null` if `ready` is true,
and will always be a string if `ready` is false.
url
: The server's url (just the path, e.g. `/users/:name/:servername/`)
where the server can be accessed if `ready` is true.
progress_url
: The API url path (starting with `/hub/api`)
where the progress API can be used to wait for the server to be ready.
See below for more details on the progress API.
last_activity
: ISO8601 timestamp indicating when activity was last observed on the server
started
: ISO801 timestamp indicating when the server was last started
We've seen the `servers` model with no servers and with one `ready` server.
Here is what it looks like immediately after requesting a server launch,
while the server is not ready yet:
```json
"servers": {
"": {
"name": "",
"last_activity": "2021-08-03T18:48:29.093885Z",
"started": "2021-08-03T18:48:29.093885Z",
"pending": "spawn",
"ready": false,
"url": "/user/test-1/",
"user_options": {},
"progress_url": "/hub/api/users/test-1/server/progress"
}
}
```
Note that `ready` is false and `pending` is `spawn`.
This means that the server is not ready
(attempting to access it may not work)
because it isn't finished spawning yet.
We'll get more into that below in [waiting for a server][].
[waiting for a server]: waiting
(starting)=
## Starting servers
To start a server, make the request
```
POST /hub/api/users/:username/servers/[:servername]
```
**Required scope: `servers`**
(omit servername for the default server)
Assuming the request was valid,
there are two possible responses:
201 Created
: This status code means the launch completed and the server is ready.
It should be available at the server's URL immediately.
202 Accepted
: This is the more likely response,
and means that the server has begun launching,
but isn't immediately ready.
The server has `pending: 'spawn'` at this point.
_Aside: how quickly JupyterHub responds with `202 Accepted` is governed by the `slow_spawn_timeout` tornado setting._
(waiting)=
## Waiting for a server
If you are starting a server via the API,
there's a good change you want to know when it's ready.
There are two ways to do with:
1. {ref}`Polling the server model <polling>`
2. the {ref}`progress API <progress>`
(polling)=
### Polling the server model
The simplest way to check if a server is ready
is to request the user model.
If:
1. the server name is in the user's `servers` model, and
2. `servers['servername']['ready']` is true
A Python example, checking if a server is ready:
```python
def server_ready(hub_url, user, server_name="", token):
r = requests.get(
f"{hub_url}/hub/api/users/{user}/servers/{server_name}",
headers={"Authorization": f"token {token}"},
)
r.raise_for_status()
user_model = r.json()
servers = user_model.get("servers", {})
if server_name not in servers:
return False
server = servers[server_name]
if server['ready']:
print(f"Server {user}/{server_name} ready at {server['url']}")
return True
else:
print(f"Server {user}/{server_name} not ready, pending {server['pending']}")
return False
```
You can keep making this check until `ready` is true.
(progress)=
### Progress API
The most _efficient_ way to wait for a server to start is the progress API.
The progress URL is available in the server model under `progress_url`,
and has the form `/hub/api/users/:user/servers/:servername/progress`.
_the default server progress can be accessed at `:user/servers//progress` or `:user/server/progress`_
```
GET /hub/api/users/:user/servers/:servername/progress
```
**Required scope: `read:servers`**
This is an [EventStream][] API.
In an event stream, messages are _streamed_ and delivered on lines of the form:
```
data: {"progress": 10, "message": "...", ...}
```
where the line after `data:` contains a JSON-serialized dictionary.
Lines that do not start with `data:` should be ignored.
[eventstream]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#examples
progress events have the form:
```python
{
"progress": 0-100,
"message": "",
"ready": True, # or False
}
```
progress
: integer, 0-100
message
: string message describing progress stages
ready
: present and true only for the last event when the server is ready
url
: only present if `ready` is true; will be the server's url
the progress API can be used even with fully ready servers.
If the server is ready,
there will only be one event that looks like:
```json
{
"progress": 100,
"ready": true,
"message": "Server ready at /user/test-1/",
"html_message": "Server ready at <a href=\"/user/test-1/\">/user/test-1/</a>",
"url": "/user/test-1/"
}
```
where `ready` and `url` are the same as in the server model (`ready` will always be true).
A typical complete stream from the event-stream API:
```
data: {"progress": 0, "message": "Server requested"}
data: {"progress": 50, "message": "Spawning server..."}
data: {"progress": 100, "ready": true, "message": "Server ready at /user/test-user/", "html_message": "Server ready at <a href=\"/user/test-user/\">/user/test-user/</a>", "url": "/user/test-user/"}
```
Here is a Python example for consuming an event stream:
```{literalinclude} ../../../examples/server-api/start-stop-server.py
:language: python
:pyobject: event_stream
```
(stopping)=
## Stopping servers
Servers can be stopped with a DELETE request:
```
DELETE /hub/api/users/:user/servers/[:servername]
```
**Required scope: `servers`**
Like start, delete may not complete immediately.
The DELETE request has two possible response codes:
204 Deleted
: This status code means the delete completed and the server is fully stopped.
It will now be absent from the user `servers` model.
202 Accepted
: Like start, `202` means your request was accepted,
but is not yet complete.
The server has `pending: 'stop'` at this point.
Unlike start, there is no progress API for stop.
To wait for stop to finish, you must poll the user model
and wait for the server to disappear from the user `servers` model.
```{literalinclude} ../../../examples/server-api/start-stop-server.py
:language: python
:pyobject: stop_server
```
(communicating)=
## Communicating with servers
JupyterHub tokens with the the `access:servers` scope
can be used to communicate with servers themselves.
This can be the same token you used to launch your service.
```{note}
Access scopes are new in JupyterHub 2.0.
To access servers in JupyterHub 1.x,
a token must be owned by the same user as the server,
*or* be an admin token if admin_access is enabled.
```
The URL returned from a server model is the url path suffix,
e.g. `/user/:name/` to append to the jupyterhub base URL.
For instance, `{hub_url}{server_url}`,
where `hub_url` would be e.g. `http://127.0.0.1:8000` by default,
and `server_url` `/user/myname`,
for a full url of `http://127.0.0.1:8000/user/myname`.
## Python example
The JupyterHub repo includes a complete example in {file}`examples/server-api`
tying all this together.
To summarize the steps:
1. get user info from `/user/:name`
2. the server model includes a `ready` state to tell you if it's ready
3. if it's not ready, you can follow up with `progress_url` to wait for it
4. if it is ready, you can use the `url` field to link directly to the running server
The example demonstrates starting and stopping servers via the JupyterHub API,
including waiting for them to start via the progress API,
as well as waiting for them to stop via polling the user model.
```{literalinclude} ../../../examples/server-api/start-stop-server.py
:language: python
:start-at: def event_stream
:end-before: def main
```

View File

@@ -86,19 +86,10 @@ Hub-Managed Service would include:
This example would be configured as follows in `jupyterhub_config.py`:
```python
c.JupyterHub.load_roles = [
{
"name": "idle-culler",
"scopes": [
"read:users:activity", # read user last_activity
"servers", # start and stop servers
# 'admin:users' # needed if culling idle users as well
]
}
c.JupyterHub.services = [
{
'name': 'idle-culler',
'admin': True,
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
}
]
@@ -123,7 +114,6 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
Only for proxied web services.
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
```
For the previous 'cull idle' Service example, these environment variables
@@ -213,6 +203,8 @@ To use HubAuth, you must set the `.api_token`, either programmatically when cons
or via the `JUPYTERHUB_API_TOKEN` environment variable.
Most of the logic for authentication implementation is found in the
[`HubAuth.user_for_cookie`][hubauth.user_for_cookie]
and in the
[`HubAuth.user_for_token`][hubauth.user_for_token]
methods, which makes a request of the Hub, and returns:
@@ -223,9 +215,7 @@ methods, which makes a request of the Hub, and returns:
{
"name": "username",
"groups": ["list", "of", "groups"],
"scopes": [
"access:users:servers!server=username/",
],
"admin": False, # or True
}
```
@@ -243,8 +233,53 @@ service. See the `service-whoami-flask` example in the
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
for more details.
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
:language: python
```python
from functools import wraps
import json
import os
from urllib.parse import quote
from flask import Flask, redirect, request, Response
from jupyterhub.services.auth import HubAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubAuth(
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
cache_max_age=60,
)
app = Flask(__name__)
def authenticated(f):
"""Decorator for authenticating with the Hub"""
@wraps(f)
def decorated(*args, **kwargs):
cookie = request.cookies.get(auth.cookie_name)
token = request.headers.get(auth.auth_header_name)
if cookie:
user = auth.user_for_cookie(cookie)
elif token:
user = auth.user_for_token(token)
else:
user = None
if user:
return f(user, *args, **kwargs)
else:
# redirect to login url on failed auth
return redirect(auth.login_url + '?next=%s' % quote(request.path))
return decorated
@app.route(prefix)
@authenticated
def whoami(user):
return Response(
json.dumps(user, indent=1, sort_keys=True),
mimetype='application/json',
)
```
### Authenticating tornado services with JupyterHub
@@ -285,38 +320,25 @@ undefined, then any user will be allowed.
If you don't want to use the reference implementation
(e.g. you find the implementation a poor fit for your Flask app),
you can implement authentication via the Hub yourself.
JupyterHub is a standard OAuth2 provider,
so you can use any OAuth 2 client implementation appropriate for your toolkit.
See the [FastAPI example][] for an example of using JupyterHub as an OAuth provider with [FastAPI][],
without using any code imported from JupyterHub.
On completion of OAuth, you will have an access token for JupyterHub,
which can be used to identify the user and the permissions (scopes)
the user has authorized for your service.
You will only get to this stage if the user has the required `access:services!service=$service-name` scope.
To retrieve the user model for the token, make a request to `GET /hub/api/user` with the token in the Authorization header.
For example, using flask:
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
:language: python
```
We recommend looking at the [`HubOAuth`][huboauth] class implementation for reference,
We recommend looking at the [`HubAuth`][hubauth] class implementation for reference,
and taking note of the following process:
1. retrieve the token from the request.
2. Make an API request `GET /hub/api/user`,
with the token in the `Authorization` header.
1. retrieve the cookie `jupyterhub-services` from the request.
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
This request must be authenticated with a Hub API token in the `Authorization` header,
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
For example, with [requests][]:
```python
r = requests.get(
"http://127.0.0.1:8081/hub/api/user",
'/'.join(["http://127.0.0.1:8081/hub/api",
"authorizations/cookie/jupyterhub-services",
quote(encrypted_cookie, safe=''),
]),
headers = {
'Authorization' : f'token {api_token}',
'Authorization' : 'token %s' % api_token,
},
)
r.raise_for_status()
@@ -325,27 +347,13 @@ and taking note of the following process:
3. On success, the reply will be a JSON model describing the user:
```python
```json
{
"name": "inara",
# groups may be omitted, depending on permissions
"groups": ["serenity", "guild"],
# scopes is new in JupyterHub 2.0
"scopes": [
"access:services",
"read:users:name",
"read:users!user=inara",
"..."
]
"groups": ["serenity", "guild"]
}
```
The `scopes` field can be used to manage access.
Note: a user will have access to a service to complete oauth access to the service for the first time.
Individual permissions may be revoked at any later point without revoking the token,
in which case the `scopes` field in this model should be checked on each access.
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
An example of using an Externally-Managed Service and authentication is
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
@@ -354,10 +362,9 @@ section on securing the notebook viewer.
[requests]: http://docs.python-requests.org/en/master/
[services_auth]: ../api/services.auth.html
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
[hubauth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
[fastapi]: https://fastapi.tiangolo.com
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler

View File

@@ -37,13 +37,14 @@ Some examples include:
Information about the user can be retrieved from `self.user`,
an object encapsulating the user's name, authentication, and server info.
The return value of `Spawner.start` should be the `(ip, port)` of the running server,
or a full URL as a string.
The return value of `Spawner.start` should be the (ip, port) of the running server.
**NOTE:** When writing coroutines, _never_ `yield` in between a database change and a commit.
Most `Spawner.start` functions will look similar to this example:
```python
async def start(self):
def start(self):
self.ip = '127.0.0.1'
self.port = random_port()
# get environment variables,
@@ -55,10 +56,8 @@ async def start(self):
cmd.extend(self.cmd)
cmd.extend(self.get_args())
await self._actually_start_server_somehow(cmd, env)
# url may not match self.ip:self.port, but it could!
url = self._get_connectable_url()
return url
yield self._actually_start_server_somehow(cmd, env)
return (self.ip, self.port)
```
When `Spawner.start` returns, the single-user server process should actually be running,
@@ -66,48 +65,6 @@ not just requested. JupyterHub can handle `Spawner.start` being very slow
(such as PBS-style batch queues, or instantiating whole AWS instances)
via relaxing the `Spawner.start_timeout` config value.
#### Note on IPs and ports
`Spawner.ip` and `Spawner.port` attributes set the _bind_ url,
which the single-user server should listen on
(passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable).
The _return_ value is the ip and port (or full url) the Hub should _connect to_.
These are not necessarily the same, and usually won't be in any Spawner that works with remote resources or containers.
The default for Spawner.ip, and Spawner.port is `127.0.0.1:{random}`,
which is appropriate for Spawners that launch local processes,
where everything is on localhost and each server needs its own port.
For remote or container Spawners, it will often make sense to use a different value,
such as `ip = '0.0.0.0'` and a fixed port, e.g. `8888`.
The defaults can be changed in the class,
preserving configuration with traitlets:
```python
from traitlets import default
from jupyterhub.spawner import Spawner
class MySpawner(Spawner):
@default("ip")
def _default_ip(self):
return '0.0.0.0'
@default("port")
def _default_port(self):
return 8888
async def start(self):
env = self.get_env()
cmd = []
# get jupyterhub command to run,
# typically ['jupyterhub-singleuser']
cmd.extend(self.cmd)
cmd.extend(self.get_args())
remote_server_info = await self._actually_start_server_somehow(cmd, env)
url = self.get_public_url_from(remote_server_info)
return url
```
### Spawner.poll
`Spawner.poll` should check if the spawner is still running.
@@ -250,73 +207,6 @@ Additionally, configurable attributes for your spawner will
appear in jupyterhub help output and auto-generated configuration files
via `jupyterhub --generate-config`.
## Environment variables and command-line arguments
Spawners mainly do one thing: launch a command in an environment.
The command-line is constructed from user configuration:
- Spawner.cmd (default: `['jupterhub-singleuser']`)
- Spawner.args (cli args to pass to the cmd, default: empty)
where the configuration:
```python
c.Spawner.cmd = ["my-singleuser-wrapper"]
c.Spawner.args = ["--debug", "--flag"]
```
would result in spawning the command:
```bash
my-singleuser-wrapper --debug --flag
```
The `Spawner.get_args()` method is how Spawner.args is accessed,
and can be used by Spawners to customize/extend user-provided arguments.
Prior to 2.0, JupyterHub unconditionally added certain options _if specified_ to the command-line,
such as `--ip={Spawner.ip}` and `--port={Spawner.port}`.
These have now all been moved to environment variables,
and from JupyterHub 2.0,
the command-line launched by JupyterHub is fully specified by overridable configuration `Spawner.cmd + Spawner.args`.
Most process configuration is passed via environment variables.
Additional variables can be specified via the `Spawner.environment` configuration.
The process environment is returned by `Spawner.get_env`, which specifies the following environment variables:
- JUPYTERHUB*SERVICE_URL - the \_bind* url where the server should launch its http server (`http://127.0.0.1:12345`).
This includes Spawner.ip and Spawner.port; _new in 2.0, prior to 2.0 ip,port were on the command-line and only if specified_
- JUPYTERHUB_SERVICE_PREFIX - the URL prefix the service will run on (e.g. `/user/name/`)
- JUPYTERHUB_USER - the JupyterHub user's username
- JUPYTERHUB_SERVER_NAME - the server's name, if using named servers (default server has an empty name)
- JUPYTERHUB_API_URL - the full url for the JupyterHub API (http://17.0.0.1:8001/hub/api)
- JUPYTERHUB_BASE_URL - the base url of the whole jupyterhub deployment, i.e. the bit before `hub/` or `user/`,
as set by c.JupyterHub.base_url (default: `/`)
- JUPYTERHUB_API_TOKEN - the API token the server can use to make requests to the Hub.
This is also the OAuth client secret.
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
Optional environment variables, depending on configuration:
- JUPYTERHUB*SSL*[KEYFILE|CERTFILE|CLIENT_CI] - SSL configuration, when internal_ssl is enabled
- JUPYTERHUB_ROOT_DIR - the root directory of the server (notebook directory), when Spawner.notebook_dir is defined (new in 2.0)
- JUPYTERHUB_DEFAULT_URL - the default URL for the server (for redirects from /user/:name/),
if Spawner.default_url is defined
(new in 2.0, previously passed via cli)
- JUPYTERHUB_DEBUG=1 - generic debug flag, sets maximum log level when Spawner.debug is True
(new in 2.0, previously passed via cli)
- JUPYTERHUB_DISABLE_USER_CONFIG=1 - disable loading user config,
sets maximum log level when Spawner.debug is True (new in 2.0,
previously passed via cli)
- JUPYTERHUB*[MEM|CPU]*[LIMIT_GUARANTEE] - the values of cpu and memory limits and guarantees.
These are not expected to be enforced by the process,
but are made available as a hint,
e.g. for resource monitoring extensions.
## Spawners, resource limits, and guarantees (Optional)
Some spawners of the single-user notebook servers allow setting limits or

View File

@@ -131,6 +131,6 @@ A handy website for testing your deployment is
If you believe youve found a security vulnerability in JupyterHub, or any
Jupyter project, please report it to
[security@ipython.org](mailto:security@ipython.org). If you prefer to encrypt
[security@ipython.org](mailto:security@iypthon.org). If you prefer to encrypt
your security reports, you can use [this PGP public
key](https://jupyter-notebook.readthedocs.io/en/stable/_downloads/ipython_security.asc).

View File

@@ -10,7 +10,7 @@ from jupyter_client.localinterfaces import public_ips
def create_dir_hook(spawner):
"""Create directory"""
""" Create directory """
username = spawner.user.name # get the username
volume_path = os.path.join('/volumes/jupyterhub', username)
if not os.path.exists(volume_path):
@@ -20,7 +20,7 @@ def create_dir_hook(spawner):
def clean_dir_hook(spawner):
"""Delete directory"""
""" Delete directory """
username = spawner.user.name # get the username
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
if os.path.exists(temp_path) and os.path.isdir(temp_path):

View File

@@ -13,7 +13,7 @@ if not api_token:
c.JupyterHub.services = [
{
'name': 'external-oauth',
'oauth_client_id': "service-oauth-client-test",
'oauth_client_id': "whoami-oauth-client-test",
'api_token': api_token,
'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback',
}

View File

@@ -9,7 +9,7 @@ if [[ -z "${JUPYTERHUB_API_TOKEN}" ]]; then
fi
# 2. oauth client ID
export JUPYTERHUB_CLIENT_ID='service-oauth-client-test'
export JUPYTERHUB_CLIENT_ID='whoami-oauth-client-test'
# 3. where the Hub is
export JUPYTERHUB_URL='http://127.0.0.1:8000'

View File

@@ -9,7 +9,7 @@ if [[ -z "${JUPYTERHUB_API_TOKEN}" ]]; then
fi
# 2. oauth client ID
export JUPYTERHUB_CLIENT_ID="service-oauth-client-test"
export JUPYTERHUB_CLIENT_ID="whoami-oauth-client-test"
# 3. what URL to run on
export JUPYTERHUB_SERVICE_PREFIX='/'
export JUPYTERHUB_SERVICE_URL='http://127.0.0.1:5555'

View File

@@ -1,23 +1,10 @@
# Configuration file for jupyterhub (postgres example).
c = get_config() # noqa
c = get_config()
# Add some users
c.Authenticator.allowed_users = {'ganymede', 'io', 'rhea'}
c.JupyterHub.load_roles = [
{
"name": "user-admin",
"scopes": [
"admin:groups",
"admin:users",
"admin:servers",
],
"users": [
"rhea",
],
}
]
# Add some users.
c.JupyterHub.admin_users = {'rhea'}
c.Authenticator.whitelist = {'ganymede', 'io', 'rhea'}
# These environment variables are automatically supplied by the linked postgres
# container.

View File

@@ -1,55 +0,0 @@
# create a role with permissions to:
# 1. start/stop servers, and
# 2. access the server API
c.JupyterHub.load_roles = [
{
"name": "launcher",
"scopes": [
"servers", # manage servers
"access:servers", # access servers themselves
],
# assign role to our 'launcher' service
"services": ["launcher"],
}
]
# persist token to a file, to share it with the launch-server.py script
import pathlib
import secrets
here = pathlib.Path(__file__).parent
token_file = here.joinpath("service-token")
if token_file.exists():
with token_file.open("r") as f:
token = f.read()
else:
token = secrets.token_hex(16)
with token_file.open("w") as f:
f.write(token)
# define our service
c.JupyterHub.services = [
{
"name": "launcher",
"api_token": token,
}
]
# ensure spawn requests return immediately,
# rather than waiting up to 10 seconds for spawn to complete
# this ensures that we use the progress API
c.JupyterHub.tornado_settings = {"slow_spawn_timeout": 0}
# create our test-user
c.Authenticator.allowed_users = {
'test-user',
}
# testing boilerplate: fake auth/spawner, localhost. Don't use this for real!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1'

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env python3
"""
Example of starting/stopping a server via the JupyterHub API
1. get user status
2. start server
3. wait for server to be ready via progress api
4. make a request to the server itself
5. stop server via API
6. wait for server to finish stopping
"""
import json
import logging
import pathlib
import time
import requests
log = logging.getLogger(__name__)
def get_token():
"""boilerplate: get token from share file.
Make sure to start jupyterhub in this directory first
"""
here = pathlib.Path(__file__).parent
token_file = here.joinpath("service-token")
log.info(f"Loading token from {token_file}")
with token_file.open("r") as f:
token = f.read().strip()
return token
def make_session(token):
"""Create a requests.Session with our service token in the Authorization header"""
session = requests.Session()
session.headers = {"Authorization": f"token {token}"}
return session
def event_stream(session, url):
"""Generator yielding events from a JSON event stream
For use with the server progress API
"""
r = session.get(url, stream=True)
r.raise_for_status()
for line in r.iter_lines():
line = line.decode('utf8', 'replace')
# event lines all start with `data:`
# all other lines should be ignored (they will be empty)
if line.startswith('data:'):
yield json.loads(line.split(':', 1)[1])
def start_server(session, hub_url, user, server_name=""):
"""Start a server for a jupyterhub user
Returns the full URL for accessing the server
"""
user_url = f"{hub_url}/hub/api/users/{user}"
log_name = f"{user}/{server_name}".rstrip("/")
# step 1: get user status
r = session.get(user_url)
r.raise_for_status()
user_model = r.json()
# if server is not 'active', request launch
if server_name not in user_model.get('servers', {}):
log.info(f"Starting server {log_name}")
r = session.post(f"{user_url}/servers/{server_name}")
r.raise_for_status()
if r.status_code == 201:
log.info(f"Server {log_name} is launched and ready")
elif r.status_code == 202:
log.info(f"Server {log_name} is launching...")
else:
log.warning(f"Unexpected status: {r.status_code}")
r = session.get(user_url)
r.raise_for_status()
user_model = r.json()
# report server status
server = user_model['servers'][server_name]
if server['pending']:
status = f"pending {server['pending']}"
elif server['ready']:
status = "ready"
else:
# shouldn't be possible!
raise ValueError(f"Unexpected server state: {server}")
log.info(f"Server {log_name} is {status}")
# wait for server to be ready using progress API
progress_url = user_model['servers'][server_name]['progress_url']
for event in event_stream(session, f"{hub_url}{progress_url}"):
log.info(f"Progress {event['progress']}%: {event['message']}")
if event.get("ready"):
server_url = event['url']
break
else:
# server never ready
raise ValueError(f"{log_name} never started!")
# at this point, we know the server is ready and waiting to receive requests
# return the full URL where the server can be accessed
return f"{hub_url}{server_url}"
def stop_server(session, hub_url, user, server_name=""):
"""Stop a server via the JupyterHub API
Returns when the server has finished stopping
"""
# step 1: get user status
user_url = f"{hub_url}/hub/api/users/{user}"
server_url = f"{user_url}/servers/{server_name}"
log_name = f"{user}/{server_name}".rstrip("/")
log.info(f"Stopping server {log_name}")
r = session.delete(server_url)
if r.status_code == 404:
log.info(f"Server {log_name} already stopped")
r.raise_for_status()
if r.status_code == 204:
log.info(f"Server {log_name} stopped")
return
# else: 202, stop requested, but not complete
# wait for stop to finish
log.info(f"Server {log_name} stopping...")
# wait for server to be done stopping
while True:
r = session.get(user_url)
r.raise_for_status()
user_model = r.json()
if server_name not in user_model.get("servers", {}):
log.info(f"Server {log_name} stopped")
return
server = user_model["servers"][server_name]
if not server['pending']:
raise ValueError(f"Waiting for {log_name}, but no longer pending.")
log.info(f"Server {log_name} pending: {server['pending']}")
# wait to poll again
time.sleep(1)
def main():
"""Start and stop one server
Uses test-user and hub from jupyterhub_config.py in this directory
"""
user = "test-user"
hub_url = "http://127.0.0.1:8000"
session = make_session(get_token())
server_url = start_server(session, hub_url, user)
r = session.get(f"{server_url}/api/status")
r.raise_for_status()
log.info(f"Server status: {r.text}")
stop_server(session, hub_url, user)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()

View File

@@ -6,17 +6,15 @@ that appear when JupyterHub renders pages.
To run the service as a hub-managed service simply include in your JupyterHub
configuration file something like:
```python
c.JupyterHub.services = [
{
'name': 'announcement',
'url': 'http://127.0.0.1:8888',
'command': [sys.executable, "-m", "announcement", "--port", "8888"],
}
]
```
c.JupyterHub.services = [
{
'name': 'announcement',
'url': 'http://127.0.0.1:8888',
'command': [sys.executable, "-m", "announcement"],
}
]
This starts the announcements service up at `/services/announcement/` when
This starts the announcements service up at `/services/announcement` when
JupyterHub launches. By default the announcement text is empty.
The `announcement` module has a configurable port (default 8888) and an API
@@ -25,28 +23,15 @@ that environment variable is set or `/` if it is not.
## Managing the Announcement
Users with permission can set the announcement text with an API token:
Admin users can set the announcement text with an API token:
$ curl -X POST -H "Authorization: token <token>" \
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
https://.../services/announcement/
To grant permission, add a role (JupyterHub 2.0) with access to the announcement service:
```python
# grant the 'announcer' permission to access the announcement service
c.JupyterHub.load_roles = [
{
"name": "announcers",
"users": ["announcer"], # or groups
"scopes": ["access:services!service=announcement"],
}
]
```
https://.../services/announcement
Anyone can read the announcement:
$ curl https://.../services/announcement/ | python -m json.tool
$ curl https://.../services/announcement | python -m json.tool
{
announcement: "JupyterHub will be upgraded on August 14!",
timestamp: "...",
@@ -56,11 +41,10 @@ Anyone can read the announcement:
The time the announcement was posted is recorded in the `timestamp` field and
the user who posted the announcement is recorded in the `user` field.
To clear the announcement text, send a DELETE request.
This has the same permission requirement.
To clear the announcement text, just DELETE. Only admin users can do this.
$ curl -X DELETE -H "Authorization: token <token>" \
https://.../services/announcement/
$ curl -X POST -H "Authorization: token <token>" \
https://.../services/announcement
## Seeing the Announcement in JupyterHub

View File

@@ -13,6 +13,9 @@ from jupyterhub.services.auth import HubAuthenticated
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
"""Dynamically manage page announcements"""
hub_users = []
allow_admin = True
def initialize(self, storage):
"""Create storage for announcement text"""
self.storage = storage

View File

@@ -2,18 +2,11 @@ import sys
# To run the announcement service managed by the hub, add this.
port = 9999
c.JupyterHub.services = [
{
'name': 'announcement',
'url': f'http://127.0.0.1:{port}',
'command': [
sys.executable,
"-m",
"announcement",
'--port',
str(port),
],
'url': 'http://127.0.0.1:8888',
'command': [sys.executable, "-m", "announcement"],
}
]
@@ -21,19 +14,3 @@ c.JupyterHub.services = [
# for an example of how to do this.
c.JupyterHub.template_paths = ["templates"]
c.Authenticator.allowed_users = {"announcer", "otheruser"}
# grant the 'announcer' permission to access the announcement service
c.JupyterHub.load_roles = [
{
"name": "announcers",
"users": ["announcer"],
"scopes": ["access:services!service=announcement"],
}
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -16,7 +16,6 @@ jupyterhub --ip=127.0.0.1
```
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
Login with username 'test-user' and any password.
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
@@ -25,10 +24,10 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/
{"Hello":"World"}
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
{"detail":"Must login with token parameter, or Authorization bearer header"}
{"detail":"Must login with token parameter, cookie, or header"}
$ curl -X POST http://127.0.0.1:8000/hub/api/users/test-user/tokens \
-d '{"auth": {"username": "test-user", "password": "mypasswd!"}}' \
$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \
-d '{"username": "myname", "password": "mypasswd!"}' \
| jq '.token'
"3fee13ce6d2845da9bd5f2c2170d3428"
@@ -36,18 +35,13 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/me \
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
| jq .
{
"name": "test-user",
"name": "myname",
"admin": false,
"groups": [],
"server": null,
"pending": null,
"last_activity": "2021-05-21T09:13:00.514309+00:00",
"servers": null,
"scopes": [
"access:services",
"access:servers!user=test-user",
"...",
]
"last_activity": "2021-04-07T18:05:11.587638+00:00",
"servers": null
}
```

View File

@@ -1,6 +1,5 @@
from datetime import datetime
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
@@ -23,12 +22,11 @@ class Server(BaseModel):
class User(BaseModel):
name: str
admin: bool
groups: Optional[List[str]]
groups: List[str]
server: Optional[str]
pending: Optional[str]
last_activity: datetime
servers: Optional[Dict[str, Server]]
scopes: List[str]
servers: Optional[List[Server]]
# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses

View File

@@ -1,4 +1,3 @@
import json
import os
from fastapi import HTTPException
@@ -28,12 +27,6 @@ auth_by_header = OAuth2AuthorizationCodeBearer(
### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an
### access_token, which it returns to browser, which places in Authorization header.
if os.environ.get("JUPYTERHUB_OAUTH_SCOPES"):
# typically ["access:services", "access:services!service=$service_name"]
access_scopes = json.loads(os.environ["JUPYTERHUB_OAUTH_SCOPES"])
else:
access_scopes = ["access:services"]
### For consideration: optimize performance with a cache instead of
### always hitting the Hub api?
async def get_current_user(
@@ -65,15 +58,4 @@ async def get_current_user(
},
)
user = User(**resp.json())
if any(scope in user.scopes for scope in access_scopes):
return user
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={
"msg": f"User not authorized: {user.name}",
"request_url": str(resp.request.url),
"token": token,
"user": resp.json(),
},
)
return user

View File

@@ -24,21 +24,8 @@ c.JupyterHub.services = [
"name": service_name,
"url": "http://127.0.0.1:10202",
"command": ["uvicorn", "app:app", "--port", "10202"],
"admin": True,
"oauth_redirect_uri": oauth_redirect_uri,
"environment": {"PUBLIC_HOST": public_host},
}
]
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to services
"scopes": ["self", "access:services"],
},
]
# dummy for testing, create test-user
c.Authenticator.allowed_users = {"test-user"}
c.JupyterHub.authenticator_class = "dummy"
c.JupyterHub.spawner_class = "simple"

View File

@@ -1,35 +1,15 @@
# our user list
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
service_name = 'shared-notebook'
service_port = 9999
group_name = 'shared'
# ellisonbg and willingc have access to a shared server:
# ellisonbg and willingc are in a group that will access the shared server:
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
# start the notebook server as a service
c.JupyterHub.services = [
{
'name': service_name,
'url': f'http://127.0.0.1:{service_port}',
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
'name': 'shared-notebook',
'url': 'http://127.0.0.1:9999',
'api_token': 'super-secret',
}
]
# This "role assignment" is what grants members of the group
# access to the service
c.JupyterHub.load_roles = [
{
"name": "shared-notebook",
"groups": [group_name],
"scopes": [f"access:services!service={service_name}"],
},
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -1,11 +1,9 @@
#!/bin/bash -l
set -e
# these must match the values in jupyterhub_config.py
export JUPYTERHUB_API_TOKEN=c3a29e5d386fd7c9aa1e8fe9d41c282ec8b
export JUPYTERHUB_API_TOKEN=super-secret
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
export JUPYTERHUB_SERVICE_NAME=shared-notebook
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
jupyterhub-singleuser
jupyterhub-singleuser \
--group='shared'

View File

@@ -1,35 +1,19 @@
# our user list
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
# ellisonbg and willingc have access to a shared server:
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
service_name = 'shared-notebook'
service_port = 9999
group_name = 'shared'
# ellisonbg and willingc have access to a shared server:
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
# start the notebook server as a service
c.JupyterHub.services = [
{
'name': service_name,
'url': f'http://127.0.0.1:{service_port}',
'command': ['jupyterhub-singleuser', '--debug'],
'url': 'http://127.0.0.1:{}'.format(service_port),
'command': ['jupyterhub-singleuser', '--group=shared', '--debug'],
}
]
# This "role assignment" is what grants members of the group
# access to the service
c.JupyterHub.load_roles = [
{
"name": "shared-notebook",
"groups": [group_name],
"scopes": [f"access:services!service={service_name}"],
},
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -1,6 +1,6 @@
# Authenticating a flask service with JupyterHub
Uses `jupyterhub.services.HubOAuth` to authenticate requests with the Hub in a [flask][] application.
Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application.
## Run
@@ -8,7 +8,7 @@ Uses `jupyterhub.services.HubOAuth` to authenticate requests with the Hub in a [
jupyterhub --ip=127.0.0.1
2. Visit http://127.0.0.1:8000/services/whoami/
2. Visit http://127.0.0.1:8000/services/whoami/ or http://127.0.0.1:8000/services/whoami-oauth/
After logging in with your local-system credentials, you should see a JSON dump of your user info:

View File

@@ -5,12 +5,10 @@ c.JupyterHub.services = [
'command': ['flask', 'run', '--port=10101'],
'environment': {'FLASK_APP': 'whoami-flask.py'},
},
{
'name': 'whoami-oauth',
'url': 'http://127.0.0.1:10201',
'command': ['flask', 'run', '--port=10201'],
'environment': {'FLASK_APP': 'whoami-oauth.py'},
},
]
# dummy auth and simple spawner for testing
# any username and password will work
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.authenticator_class = 'dummy'
# listen only on localhost while testing with wide-open auth
c.JupyterHub.ip = '127.0.0.1'

View File

@@ -4,48 +4,42 @@ whoami service authentication with the Hub
"""
import json
import os
import secrets
from functools import wraps
from urllib.parse import quote
from flask import Flask
from flask import make_response
from flask import redirect
from flask import request
from flask import Response
from flask import session
from jupyterhub.services.auth import HubOAuth
from jupyterhub.services.auth import HubAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
auth = HubAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
app = Flask(__name__)
# encryption key for session cookies
app.secret_key = secrets.token_bytes(32)
def authenticated(f):
"""Decorator for authenticating with the Hub via OAuth"""
"""Decorator for authenticating with the Hub"""
@wraps(f)
def decorated(*args, **kwargs):
token = session.get("token")
if token:
cookie = request.cookies.get(auth.cookie_name)
token = request.headers.get(auth.auth_header_name)
if cookie:
user = auth.user_for_cookie(cookie)
elif token:
user = auth.user_for_token(token)
else:
user = None
if user:
return f(user, *args, **kwargs)
else:
# redirect to login url on failed auth
state = auth.generate_state(next_url=request.path)
response = make_response(redirect(auth.login_url + '&state=%s' % state))
response.set_cookie(auth.state_cookie_name, state)
return response
return redirect(auth.login_url + '?next=%s' % quote(request.path))
return decorated
@@ -56,24 +50,3 @@ def whoami(user):
return Response(
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
)
@app.route(prefix + 'oauth_callback')
def oauth_callback():
code = request.args.get('code', None)
if code is None:
return 403
# validate state field
arg_state = request.args.get('state', None)
cookie_state = request.cookies.get(auth.state_cookie_name)
if arg_state is None or arg_state != cookie_state:
# state doesn't match
return 403
token = auth.token_for_code(code)
# store token in session cookie
session["token"] = token
next_url = auth.get_next_url(cookie_state) or prefix
response = make_response(redirect(next_url))
return response

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
whoami service authentication with the Hub
"""
import json
import os
from functools import wraps
from flask import Flask
from flask import make_response
from flask import redirect
from flask import request
from flask import Response
from jupyterhub.services.auth import HubOAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
app = Flask(__name__)
def authenticated(f):
"""Decorator for authenticating with the Hub via OAuth"""
@wraps(f)
def decorated(*args, **kwargs):
token = request.cookies.get(auth.cookie_name)
if token:
user = auth.user_for_token(token)
else:
user = None
if user:
return f(user, *args, **kwargs)
else:
# redirect to login url on failed auth
state = auth.generate_state(next_url=request.path)
response = make_response(redirect(auth.login_url + '&state=%s' % state))
response.set_cookie(auth.state_cookie_name, state)
return response
return decorated
@app.route(prefix)
@authenticated
def whoami(user):
return Response(
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
)
@app.route(prefix + 'oauth_callback')
def oauth_callback():
code = request.args.get('code', None)
if code is None:
return 403
# validate state field
arg_state = request.args.get('state', None)
cookie_state = request.cookies.get(auth.state_cookie_name)
if arg_state is None or arg_state != cookie_state:
# state doesn't match
return 403
token = auth.token_for_code(code)
next_url = auth.get_next_url(cookie_state) or prefix
response = make_response(redirect(next_url))
response.set_cookie(auth.cookie_name, token)
return response

View File

@@ -2,15 +2,15 @@
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
There is an implementation each of api-token-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
There is an implementation each of cookie-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
## Run
1. Launch JupyterHub and the `whoami` services with
1. Launch JupyterHub and the `whoami service` with
jupyterhub --ip=127.0.0.1
2. Visit http://127.0.0.1:8000/services/whoami-oauth
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
After logging in with your local-system credentials, you should see a JSON dump of your user info:
@@ -24,65 +24,15 @@ After logging in with your local-system credentials, you should see a JSON dump
}
```
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
and making a direct request:
```bash
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
{
"admin": false,
"created": "2021-05-21T09:47:41.299400Z",
"groups": [],
"kind": "user",
"last_activity": "2021-05-21T09:49:08.290745Z",
"name": "test",
"pending": null,
"roles": [
"user"
],
"scopes": [
"access:services",
"access:servers!user=test",
"read:users!user=test",
"read:users:activity!user=test",
"read:users:groups!user=test",
"read:users:name!user=test",
"read:servers!user=test",
"read:tokens!user=test",
"users!user=test",
"users:activity!user=test",
"users:groups!user=test",
"users:name!user=test",
"servers!user=test",
"tokens!user=test"
],
"server": null
}
```
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
and assign users to the scope.
The jupyterhub_config.py grants access for all users to all services via the default 'user' role, with:
```python
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to all services
"scopes": ["access:services", "self"],
}
]
```
You may set the `hub_users` configuration in the service script
to restrict access to the service to a whitelist of allowed users.
By default, any authenticated user is allowed.
A similar service could be run externally, by setting the JupyterHub service environment variables:
JUPYTERHUB_API_TOKEN
JUPYTERHUB_SERVICE_PREFIX
JUPYTERHUB_OAUTH_SCOPES
JUPYTERHUB_CLIENT_ID # for whoami-oauth only
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.

View File

@@ -2,7 +2,7 @@ import sys
c.JupyterHub.services = [
{
'name': 'whoami-api',
'name': 'whoami',
'url': 'http://127.0.0.1:10101',
'command': [sys.executable, './whoami.py'],
},
@@ -10,19 +10,5 @@ c.JupyterHub.services = [
'name': 'whoami-oauth',
'url': 'http://127.0.0.1:10102',
'command': [sys.executable, './whoami-oauth.py'],
'oauth_roles': ['user'],
},
]
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to all services
"scopes": ["access:services", "self"],
}
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -1,6 +1,6 @@
"""An example service authenticating with the Hub.
This example service serves `/services/whoami-oauth/`,
This example service serves `/services/whoami/`,
authenticated with the Hub,
showing the user their own info.
"""
@@ -20,6 +20,13 @@ from jupyterhub.utils import url_path_join
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
# hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# from getpass import getuser
# hub_users = {getuser()}
@authenticated
def get(self):
user_model = self.get_current_user()

View File

@@ -1,8 +1,6 @@
"""An example service authenticating with the Hub.
This serves `/services/whoami-api/`, authenticated with the Hub, showing the user their own info.
HubAuthenticated only supports token-based access.
This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info.
"""
import json
import os
@@ -18,6 +16,13 @@ from jupyterhub.services.auth import HubAuthenticated
class WhoAmIHandler(HubAuthenticated, RequestHandler):
# hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# from getpass import getuser
# hub_users = {getuser()}
@authenticated
def get(self):
user_model = self.get_current_user()

View File

@@ -1,44 +0,0 @@
{
"extends": ["plugin:react/recommended"],
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect"
}
},
"plugins": ["eslint-plugin-react", "prettier", "unused-imports"],
"env": {
"es6": true,
"browser": true
},
"rules": {
"semi": "off",
"quotes": "off",
"prettier/prettier": "warn",
"no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^regeneratorRuntime|^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
},
"overrides": [
{
"files": ["**/*.test.js", "**/*.test.jsx"],
"env": {
"jest": true
}
}
]
}

2
jsx/.gitignore vendored
View File

@@ -1,2 +0,0 @@
node_modules
build/admin-react.js

View File

@@ -1,64 +0,0 @@
# Jupyterhub Admin Dashboard - React Variant
This repository contains current updates to the Jupyterhub Admin Dashboard,
reducing the complexity from a mass of templated HTML to a simple React web application.
This will integrate with Jupyterhub, speeding up client interactions while simplifying the
admin dashboard codebase.
### Build Commands
- `yarn build`: Installs all dependencies and bundles the application
- `yarn hot`: Bundles the application and runs a mock (serverless) version on port 8000
- `yarn lint`: Lints JSX with ESLint
- `yarn lint --fix`: Lints and fixes errors JSX with ESLint / formats with Prettier
- `yarn place`: Copies the transpiled React bundle to /share/jupyterhub/static/js/admin-react.js for use.
### Good To Know
Just some basics on how the React Admin app is built.
#### General build structure:
This app is written in JSX, and then transpiled into an ES5 bundle with Babel and Webpack. All JSX components are unit tested with a mixture of Jest and Enzyme and can be run both manually and per-commit. Most logic is separated into components under the `/src/components` directory, each directory containing a `.jsx`, `.test.jsx`, and sometimes a `.css` file. These components are all pulled together, given client-side routes, and connected to the Redux store in `/src/App.jsx` which serves as an entrypoint to the application.
#### Centralized state and data management with Redux:
The app use Redux throughout the components via the `useSelector` and `useDispatch` hooks to store and update user and group data from the API. With Redux, this data is available to any connected component. This means that if one component recieves new data, they all do.
#### API functions
All API functions used by the front end are packaged as a library of props within `/src/util/withAPI.js`. This keeps our web service logic separate from our presentational logic, allowing us to connect API functionality to our components at a high level and keep the code more modular. This connection specifically happens in `/src/App.jsx`, within the route assignments.
#### Pagination
Indicies of paginated user and group data is stored in a `page` variable in the query string, as well as the `user_page` / `group_page` state variables in Redux. This allows the app to maintain two sources of truth, as well as protect the admin user's place in the collection on page reload. Limit is constant at this point and is held in the Redux state.
On updates to the paginated data, the app can respond in one of two ways. If a user/group record is either added or deleted, the pagination will reset and data will be pulled back with no offset. Alternatively, if a record is modified, the offset will remain and the change will be shown.
Code examples:
```js
// Pagination limit is pulled in from Redux.
var limit = useSelector((state) => state.limit);
// Page query string is parsed and checked
var page = parseInt(new URLQuerySearch(props.location).get("page"));
page = isNaN(page) ? 0 : page;
// A slice is created representing the records to be returned
var slice = [page * limit, limit];
// A user's notebook server status was changed from stopped to running, user data is being refreshed from the slice.
startServer().then(() => {
updateUsers(...slice)
// After data is fetched, the Redux store is updated with the data and a copy of the page number.
.then((data) => dispatchPageChange(data, page));
});
// Alternatively, a new user was added, user data is being refreshed from offset 0.
addUser().then(() => {
updateUsers(0, limit)
// After data is fetched, the Redux store is updated with the data and asserts page 0.
.then((data) => dispatchPageChange(data, 0));
});
```

View File

@@ -1,47 +0,0 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/*!
Copyright (c) 2017 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/** @license React v0.20.1
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

View File

@@ -1,6 +0,0 @@
<!DOCTYPE html>
<head></head>
<body>
<div id="admin-react-hook"></div>
<script src="admin-react.js"></script>
</body>

View File

@@ -1,67 +0,0 @@
{
"name": "jupyterhub-admin-react",
"version": "1.0.0",
"description": "React application for the Jupyter Hub admin dashboard service",
"main": "index.js",
"author": "nabarber",
"license": "BSD-3-Clause",
"scripts": {
"build": "yarn && webpack",
"hot": "webpack && webpack-dev-server",
"place": "cp -r build/admin-react.js ../share/jupyterhub/static/js/admin-react.js",
"test": "jest --verbose",
"snap": "jest --updateSnapshot",
"lint": "eslint --ext .jsx --ext .js src/",
"lint:fix": "eslint --ext .jsx --ext .js src/ --fix"
},
"babel": {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": []
},
"jest": {
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "identity-obj-proxy"
}
},
"dependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"babel-loader": "^8.2.1",
"bootstrap": "^4.5.3",
"css-loader": "^5.0.1",
"eslint-plugin-unused-imports": "^1.1.1",
"file-loader": "^6.2.0",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-dom": "^17.0.1",
"react-icons": "^4.1.0",
"react-multi-select-component": "^3.0.7",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"recompose": "^0.30.0",
"redux": "^4.0.5",
"style-loader": "^2.0.0",
"webpack": "^5.6.0",
"webpack-cli": "^3.3.4",
"webpack-dev-server": "^3.11.0"
},
"devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
"babel-jest": "^26.6.3",
"enzyme": "^3.11.0",
"eslint": "^7.18.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"prettier": "^2.2.1"
}
}

View File

@@ -1,78 +0,0 @@
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { createStore } from "redux";
import { compose } from "recompose";
import { initialState, reducers } from "./Store";
import { jhapiRequest } from "./util/jhapiUtil";
import withAPI from "./util/withAPI";
import { HashRouter, Switch, Route } from "react-router-dom";
import ServerDashboard from "./components/ServerDashboard/ServerDashboard";
import Groups from "./components/Groups/Groups";
import GroupEdit from "./components/GroupEdit/GroupEdit";
import CreateGroup from "./components/CreateGroup/CreateGroup";
import AddUser from "./components/AddUser/AddUser";
import EditUser from "./components/EditUser/EditUser";
import "./style/root.css";
const store = createStore(reducers, initialState);
const App = () => {
useEffect(() => {
let { limit, user_page, groups_page } = initialState;
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
.then((data) =>
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
)
.catch((err) => console.log(err));
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
.then((data) =>
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
)
.catch((err) => console.log(err));
});
return (
<div className="resets">
<Provider store={store}>
<HashRouter>
<Switch>
<Route
exact
path="/"
component={compose(withAPI)(ServerDashboard)}
/>
<Route exact path="/groups" component={compose(withAPI)(Groups)} />
<Route
exact
path="/group-edit"
component={compose(withAPI)(GroupEdit)}
/>
<Route
exact
path="/create-group"
component={compose(withAPI)(CreateGroup)}
/>
<Route
exact
path="/add-users"
component={compose(withAPI)(AddUser)}
/>
<Route
exact
path="/edit-user"
component={compose(withAPI)(EditUser)}
/>
</Switch>
</HashRouter>
</Provider>
</div>
);
};
ReactDOM.render(<App />, document.getElementById("react-admin-hook"));

View File

@@ -1,28 +0,0 @@
export const initialState = {
user_data: undefined,
user_page: 0,
groups_data: undefined,
groups_page: 0,
limit: window.api_page_limit,
};
export const reducers = (state = initialState, action) => {
switch (action.type) {
// Updates the client user model data and stores the page
case "USER_PAGE":
return Object.assign({}, state, {
user_page: action.value.page,
user_data: action.value.data,
});
// Updates the client group model data and stores the page
case "GROUPS_PAGE":
return Object.assign({}, state, {
groups_page: action.value.page,
groups_data: action.value.data,
});
default:
return state;
}
};

View File

@@ -1,125 +0,0 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
const AddUser = (props) => {
var [users, setUsers] = useState([]),
[admin, setAdmin] = useState(false),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
var dispatchPageChange = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};
var { addUsers, failRegexEvent, updateUsers, history } = props;
return (
<>
<div className="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Add Users</h4>
</div>
<div className="panel-body">
<form>
<div className="form-group">
<textarea
className="form-control"
id="add-user-textarea"
rows="3"
placeholder="usernames separated by line"
onBlur={(e) => {
let split_users = e.target.value.split("\n");
setUsers(split_users);
}}
></textarea>
<br></br>
<input
className="form-check-input"
type="checkbox"
value=""
id="admin-check"
onChange={(e) => setAdmin(e.target.checked)}
/>
<span> </span>
<label className="form-check-label">Admin</label>
</div>
</form>
</div>
<div className="panel-footer">
<button id="return" className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
let filtered_users = users.filter(
(e) =>
e.length > 2 &&
/[!@#$%^&*(),.?":{}|<>]/g.test(e) == false
);
if (filtered_users.length < users.length) {
setUsers(filtered_users);
failRegexEvent();
}
addUsers(filtered_users, admin)
.then((data) =>
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to create user. ${
data.status == 409 ? "User already exists." : ""
}`
)
)
.catch((err) => console.log(err));
}}
>
Add Users
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
};
AddUser.propTypes = {
addUsers: PropTypes.func,
failRegexEvent: PropTypes.func,
updateUsers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export default AddUser;

View File

@@ -1,77 +0,0 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import AddUser from "./AddUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
describe("AddUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var addUserJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<AddUser
addUsers={callbackSpy}
failRegexEvent={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(addUserJsx(mockAsync()));
expect(component.find(".container").length).toBe(1);
});
it("Removes users when they fail Regex", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
textarea = component.find("textarea").first();
textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
});
it("Correctly submits admin", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
input = component.find("input").first();
input.simulate("change", { target: { checked: true } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith([], true);
});
});

View File

@@ -1,104 +0,0 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
const CreateGroup = (props) => {
var [groupName, setGroupName] = useState(""),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
var dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
var { createGroup, updateGroups, history } = props;
return (
<>
<div className="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Create Group</h4>
</div>
<div className="panel-body">
<div className="input-group">
<input
className="group-name-input"
type="text"
id="group-name"
value={groupName}
placeholder="group name..."
onChange={(e) => {
setGroupName(e.target.value);
}}
></input>
</div>
</div>
<div className="panel-footer">
<button id="return" className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
createGroup(groupName)
.then((data) => {
return data.status < 300
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to create group. ${
data.status == 409
? "Group already exists."
: ""
}`
);
})
.catch((err) => console.log(err));
}}
>
Create
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
};
CreateGroup.propTypes = {
createGroup: PropTypes.func,
updateGroups: PropTypes.func,
failRegexEvent: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export default CreateGroup;

View File

@@ -1,66 +0,0 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import CreateGroup from "./CreateGroup";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
describe("CreateGroup Component: ", () => {
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(createGroupJsx());
expect(component.find(".container").length).toBe(1);
});
it("Calls createGroup on submit", () => {
let callbackSpy = mockAsync({ status: 200 }),
component = mount(createGroupJsx(callbackSpy)),
input = component.find("input").first(),
submit = component.find("#submit").first();
input.simulate("change", { target: { value: "" } });
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
expect(component.find(".alert.alert-danger").length).toBe(0);
});
});

View File

@@ -1,190 +0,0 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
const EditUser = (props) => {
var limit = useSelector((state) => state.limit),
[errorAlert, setErrorAlert] = useState(null);
var dispatch = useDispatch();
var dispatchPageChange = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};
var {
editUser,
deleteUser,
failRegexEvent,
noChangeEvent,
updateUsers,
history,
} = props;
if (props.location.state == undefined) {
props.history.push("/");
return <></>;
}
var { username, has_admin } = props.location.state;
var [updatedUsername, setUpdatedUsername] = useState(""),
[admin, setAdmin] = useState(has_admin);
return (
<>
<div className="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Editing user {username}</h4>
</div>
<div className="panel-body">
<form>
<div className="form-group">
<textarea
className="form-control"
id="exampleFormControlTextarea1"
rows="3"
placeholder="updated username"
onBlur={(e) => {
setUpdatedUsername(e.target.value);
}}
></textarea>
<br></br>
<input
className="form-check-input"
checked={admin}
type="checkbox"
id="admin-check"
onChange={() => setAdmin(!admin)}
/>
<span> </span>
<label className="form-check-label">Admin</label>
<br></br>
<button
id="delete-user"
className="btn btn-danger btn-sm"
onClick={() => {
deleteUser(username)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
})
.catch((err) => console.log(err));
}}
>
Delete user
</button>
</div>
</form>
</div>
<div className="panel-footer">
<button className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
if (updatedUsername == "" && admin == has_admin) {
noChangeEvent();
return;
} else if (updatedUsername != "") {
if (
updatedUsername.length > 2 &&
/[!@#$%^&*(),.?":{}|<>]/g.test(updatedUsername) == false
) {
editUser(
username,
updatedUsername != "" ? updatedUsername : username,
admin
)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
})
.catch((err) => {
console.log(err);
});
} else {
setUpdatedUsername("");
failRegexEvent();
}
} else {
editUser(username, username, admin)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
})
.catch((err) => {
console.log(err);
});
}
}}
>
Apply
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
};
EditUser.propTypes = {
location: PropTypes.shape({
state: PropTypes.shape({
username: PropTypes.string,
has_admin: PropTypes.bool,
}),
}),
history: PropTypes.shape({
push: PropTypes.func,
}),
editUser: PropTypes.func,
deleteUser: PropTypes.func,
failRegexEvent: PropTypes.func,
noChangeEvent: PropTypes.func,
updateUsers: PropTypes.func,
};
export default EditUser;

View File

@@ -1,80 +0,0 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import EditUser from "./EditUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
describe("EditUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockSync = () => jest.fn();
var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<EditUser
location={
empty ? {} : { state: { username: "foo", has_admin: false } }
}
deleteUser={callbackSpy}
editUser={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
failRegexEvent={callbackSpy}
noChangeEvent={callbackSpy}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Calls the delete user function when the button is pressed", () => {
let callbackSpy = mockAsync(),
component = mount(editUserJsx(callbackSpy)),
deleteUser = component.find("#delete-user");
deleteUser.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Submits the edits when the button is pressed", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy)),
submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Doesn't render when no data is provided", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy, true));
expect(component.find(".container").length).toBe(0);
});
});

View File

@@ -1,144 +0,0 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import GroupSelect from "../GroupSelect/GroupSelect";
const GroupEdit = (props) => {
var [selected, setSelected] = useState([]),
[changed, setChanged] = useState(false),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
const dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
var {
addToGroup,
removeFromGroup,
deleteGroup,
updateGroups,
validateUser,
history,
location,
} = props;
if (!location.state) {
history.push("/groups");
return <></>;
}
var { group_data } = location.state;
if (!group_data) return <div></div>;
return (
<div className="container">
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<h3>Editing Group {group_data.name}</h3>
<br></br>
<div className="alert alert-info">Manage group members</div>
</div>
</div>
<GroupSelect
users={group_data.users}
validateUser={validateUser}
onChange={(selection) => {
setSelected(selection);
setChanged(true);
}}
/>
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<button id="return" className="btn btn-light">
<Link to="/groups">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
// check for changes
if (!changed) {
history.push("/groups");
return;
}
let new_users = selected.filter(
(e) => !group_data.users.includes(e)
);
let removed_users = group_data.users.filter(
(e) => !selected.includes(e)
);
let promiseQueue = [];
if (new_users.length > 0)
promiseQueue.push(addToGroup(new_users, group_data.name));
if (removed_users.length > 0)
promiseQueue.push(
removeFromGroup(removed_users, group_data.name)
);
Promise.all(promiseQueue)
.then(() => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
})
.catch((err) => console.log(err));
}}
>
Apply
</button>
<button
id="delete-group"
className="btn btn-danger"
style={{ float: "right" }}
onClick={() => {
var groupName = group_data.name;
deleteGroup(groupName)
.then(() => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
})
.catch((err) => console.log(err));
}}
>
Delete Group
</button>
<br></br>
<br></br>
</div>
</div>
</div>
);
};
GroupEdit.propTypes = {
location: PropTypes.shape({
state: PropTypes.shape({
group_data: PropTypes.object,
callback: PropTypes.func,
}),
}),
history: PropTypes.shape({
push: PropTypes.func,
}),
addToGroup: PropTypes.func,
removeFromGroup: PropTypes.func,
deleteGroup: PropTypes.func,
updateGroups: PropTypes.func,
validateUser: PropTypes.func,
};
export default GroupEdit;

View File

@@ -1,100 +0,0 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import GroupEdit from "./GroupEdit";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import { act } from "react-dom/test-utils";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("GroupEdit Component: ", () => {
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
var okPacket = new Promise((resolve) => resolve(true));
var groupEditJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<GroupEdit
location={{
state: {
group_data: { users: ["foo"], name: "group" },
callback: () => {},
},
}}
addToGroup={callbackSpy}
removeFromGroup={callbackSpy}
deleteGroup={callbackSpy}
history={{ push: () => callbackSpy }}
updateGroups={callbackSpy}
validateUser={jest.fn().mockImplementation(() => okPacket)}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Adds user from input to user selectables on button click", async () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
input = component.find("#username-input"),
validateUser = component.find("#validate-user"),
submit = component.find("#submit");
input.simulate("change", { target: { value: "bar" } });
validateUser.simulate("click");
await act(() => okPacket);
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
});
it("Removes a user recently added from input from the selectables list", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
unsubmittedUser = component.find(".item.selected").last();
unsubmittedUser.simulate("click");
expect(component.find(".item").length).toBe(1);
});
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
groupUser = component.find(".item.selected").first();
groupUser.simulate("click");
expect(component.find(".item.unselected").length).toBe(1);
expect(component.find(".item").length).toBe(1);
// test deleteUser call
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
});
it("Calls deleteGroup on button click", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
deleteGroup = component.find("#delete-group").first();
deleteGroup.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
});
});

View File

@@ -1,108 +0,0 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import "./group-select.css";
const GroupSelect = (props) => {
var { onChange, validateUser, users } = props;
var [selected, setSelected] = useState(users);
var [username, setUsername] = useState("");
var [error, setError] = useState(null);
if (!users) return null;
return (
<div className="row">
{error != null ? (
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="alert alert-danger">{error}</div>
</div>
) : (
<></>
)}
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="input-group">
<input
id="username-input"
type="text"
className="form-control"
placeholder="Add by username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
}}
/>
<span className="input-group-btn">
<button
id="validate-user"
className="btn btn-default"
type="button"
onClick={() => {
validateUser(username).then((exists) => {
if (exists && !selected.includes(username)) {
let updated_selection = selected.concat([username]);
onChange(updated_selection, users);
setUsername("");
setSelected(updated_selection);
if (error != null) setError(null);
} else if (!exists) {
setError(`"${username}" is not a valid JupyterHub user.`);
}
});
}}
>
Add user
</button>
</span>
</div>
</div>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="users-container">
<hr></hr>
<div>
{selected.map((e, i) => (
<div
key={"selected" + i}
className="item selected"
onClick={() => {
let updated_selection = selected
.slice(0, i)
.concat(selected.slice(i + 1));
onChange(updated_selection, users);
setSelected(updated_selection);
}}
>
{e}
</div>
))}
{users.map((e, i) =>
selected.includes(e) ? undefined : (
<div
key={"unselected" + i}
className="item unselected"
onClick={() => {
let updated_selection = selected.concat([e]);
onChange(updated_selection, users);
setSelected(updated_selection);
}}
>
{e}
</div>
)
)}
</div>
</div>
<br></br>
<br></br>
</div>
</div>
);
};
GroupSelect.propTypes = {
onChange: PropTypes.func,
validateUser: PropTypes.func,
users: PropTypes.array,
};
export default GroupSelect;

View File

@@ -1,40 +0,0 @@
@import url(../../style/root.css);
.users-container {
width: 100%;
position: relative;
padding: 5px;
overflow-x: scroll;
}
.users-container div {
display: inline-block;
}
.users-container .item {
padding: 3px;
padding-left: 6px;
padding-right: 6px;
border-radius: 3px;
font-size: 14px;
margin-left: 4px;
margin-right: 4px;
transition: 30ms ease-in all;
cursor: pointer;
user-select: none;
border: solid 1px #dfdfdf;
}
.users-container .item.unselected {
background-color: #f7f7f7;
color: #777;
}
.users-container .item.selected {
background-color: orange;
color: white;
}
.users-container .item:hover {
opacity: 0.7;
}

View File

@@ -1,117 +0,0 @@
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const Groups = (props) => {
var user_data = useSelector((state) => state.user_data),
groups_data = useSelector((state) => state.groups_data),
groups_page = useSelector((state) => state.groups_page),
limit = useSelector((state) => state.limit),
dispatch = useDispatch(),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var { updateGroups, history } = props;
if (!groups_data || !user_data) {
return <div></div>;
}
const dispatchPageChange = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
if (groups_page != page) {
updateGroups(...slice).then((data) => {
dispatchPageChange(data, page);
});
}
return (
<div className="container">
<div className="row">
<div className="col-md-12 col-lg-10 col-lg-offset-1">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Groups</h4>
</div>
<div className="panel-body">
<ul className="list-group">
{groups_data.length > 0 ? (
groups_data.map((e, i) => (
<li className="list-group-item" key={"group-item" + i}>
<span className="badge badge-pill badge-success">
{e.users.length + " users"}
</span>
<Link
to={{
pathname: "/group-edit",
state: {
group_data: e,
user_data: user_data,
},
}}
>
{e.name}
</Link>
</li>
))
) : (
<div>
<h4>no groups created...</h4>
</div>
)}
</ul>
<PaginationFooter
endpoint="/groups"
page={page}
limit={limit}
numOffset={slice[0]}
numElements={groups_data.length}
/>
</div>
<div className="panel-footer">
<button className="btn btn-light adjacent-span-spacing">
<Link to="/">Back</Link>
</button>
<button
className="btn btn-primary adjacent-span-spacing"
onClick={() => {
history.push("/create-group");
}}
>
New Group
</button>
</div>
</div>
</div>
</div>
</div>
);
};
Groups.propTypes = {
user_data: PropTypes.array,
groups_data: PropTypes.array,
updateUsers: PropTypes.func,
updateGroups: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
};
export default Groups;

View File

@@ -1,65 +0,0 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import Groups from "./Groups";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
useDispatch: jest.fn(),
}));
describe("Groups Component: ", () => {
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter>
</Provider>
);
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
),
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders groups_data prop into links", () => {
let callbackSpy = mockAsync(),
component = mount(groupsJsx(callbackSpy)),
links = component.find("li");
expect(links.length).toBe(2);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(groupsJsx());
expect(component.html()).toBe("<div></div>");
});
});

View File

@@ -1,50 +0,0 @@
import React from "react";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import "./pagination-footer.css";
const PaginationFooter = (props) => {
let { endpoint, page, limit, numOffset, numElements } = props;
return (
<div className="pagination-footer">
<p>
Displaying {numOffset}-{numOffset + numElements}
<br></br>
<br></br>
{page >= 1 ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page - 1}`}>
<span className="active-pagination">Previous</span>
</Link>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Previous</span>
</button>
)}
{numElements >= limit ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page + 1}`}>
<span className="active-pagination">Next</span>
</Link>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Next</span>
</button>
)}
</p>
</div>
);
};
PaginationFooter.propTypes = {
endpoint: PropTypes.string,
page: PropTypes.number,
limit: PropTypes.number,
numOffset: PropTypes.number,
numElements: PropTypes.number,
};
export default PaginationFooter;

View File

@@ -1,14 +0,0 @@
@import url(../../style/root.css);
.pagination-footer * button {
margin-right: 10px;
}
.pagination-footer * .inactive-pagination {
color: gray;
cursor: not-allowed;
}
.pagination-footer * button.spaced {
color: var(--blue);
}

View File

@@ -1,308 +0,0 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const ServerDashboard = (props) => {
// sort methods
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
adminDesc = (e) => e.sort((a) => (a.admin ? -1 : 1)),
adminAsc = (e) => e.sort((a) => (a.admin ? 1 : -1)),
dateDesc = (e) =>
e.sort((a, b) =>
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? -1 : 1
),
dateAsc = (e) =>
e.sort((a, b) =>
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? 1 : -1
),
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
var [sortMethod, setSortMethod] = useState(null);
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
limit = useSelector((state) => state.limit),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
const dispatch = useDispatch();
var {
updateUsers,
shutdownHub,
startServer,
stopServer,
startAll,
stopAll,
history,
} = props;
var dispatchPageUpdate = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};
if (!user_data) {
return <div></div>;
}
if (page != user_page) {
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
}
if (sortMethod != null) {
user_data = sortMethod(user_data);
}
return (
<div className="container">
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</div>
<div className="server-dashboard-container">
<table className="table table-striped table-bordered table-hover">
<thead className="admin-table-head">
<tr>
<th id="user-header">
User{" "}
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="admin-header">
Admin{" "}
<SortHandler
sorts={{ asc: adminAsc, desc: adminDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="last-activity-header">
Last Activity{" "}
<SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="running-status-header">
Running{" "}
<SortHandler
sorts={{ asc: runningAsc, desc: runningDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="actions-header">Actions</th>
</tr>
</thead>
<tbody>
<tr className="noborder">
<td>
<Button variant="light" className="add-users-button">
<Link to="/add-users">Add Users</Link>
</Button>
</td>
<td></td>
<td></td>
<td>
{/* Start all servers */}
<Button
variant="primary"
className="start-all"
onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
return res;
})
.catch((err) => console.log(err));
}}
>
Start All
</Button>
<span> </span>
{/* Stop all servers */}
<Button
variant="danger"
className="stop-all"
onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
return res;
})
.catch((err) => console.log(err));
}}
>
Stop All
</Button>
</td>
<td>
{/* Shutdown Jupyterhub */}
<Button
variant="danger"
id="shutdown-button"
onClick={shutdownHub}
>
Shutdown Hub
</Button>
</td>
</tr>
{user_data.map((e, i) => (
<tr key={i + "row"} className="user-row">
<td>{e.name}</td>
<td>{e.admin ? "admin" : ""}</td>
<td>
{e.last_activity ? timeSince(e.last_activity) : "Never"}
</td>
<td>
{e.server != null ? (
// Stop Single-user server
<button
className="btn btn-danger btn-xs stop-button"
onClick={() =>
stopServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Stop Server
</button>
) : (
// Start Single-user server
<button
className="btn btn-primary btn-xs start-button"
onClick={() =>
startServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Start Server
</button>
)}
</td>
<td>
{/* Edit User */}
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: e.name,
has_admin: e.admin,
},
})
}
>
edit user
</button>
</td>
</tr>
))}
</tbody>
</table>
<PaginationFooter
endpoint="/"
page={page}
limit={limit}
numOffset={slice[0]}
numElements={user_data.length}
/>
<br></br>
</div>
</div>
);
};
ServerDashboard.propTypes = {
user_data: PropTypes.array,
updateUsers: PropTypes.func,
shutdownHub: PropTypes.func,
startServer: PropTypes.func,
stopServer: PropTypes.func,
startAll: PropTypes.func,
stopAll: PropTypes.func,
dispatch: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
};
const SortHandler = (props) => {
var { sorts, callback } = props;
var [direction, setDirection] = useState(undefined);
return (
<div
className="sort-icon"
onClick={() => {
if (!direction) {
callback(sorts.desc);
setDirection("desc");
} else if (direction == "asc") {
callback(sorts.desc);
setDirection("desc");
} else {
callback(sorts.asc);
setDirection("asc");
}
}}
>
{!direction ? (
<FaSort />
) : direction == "asc" ? (
<FaSortDown />
) : (
<FaSortUp />
)}
</div>
);
};
SortHandler.propTypes = {
sorts: PropTypes.object,
callback: PropTypes.func,
};
export default ServerDashboard;

View File

@@ -1,161 +0,0 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import ServerDashboard from "./ServerDashboard";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("ServerDashboard Component: ", () => {
var serverDashboardJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={callbackSpy}
shutdownHub={callbackSpy}
startServer={callbackSpy}
stopServer={callbackSpy}
startAll={callbackSpy}
stopAll={callbackSpy}
/>
</Switch>
</HashRouter>
</Provider>
);
var mockAsync = () =>
jest
.fn()
.mockImplementation(() =>
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
);
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders users from props.user_data into table", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
expect(userRows.length).toBe(2);
});
it("Renders correctly the status of a single-user server", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
// Renders .stop-button when server is started
// Should be 1 since user foo is started
expect(userRows.at(0).find(".stop-button").length).toBe(1);
// Renders .start-button when server is stopped
// Should be 1 since user bar is stopped
expect(userRows.at(1).find(".start-button").length).toBe(1);
});
it("Invokes the startServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
startBtn = component.find(".start-button");
startBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the stopServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
stopBtn = component.find(".stop-button");
stopBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the shutdownHub event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
shutdownBtn = component.find("#shutdown-button").first();
shutdownBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Sorts according to username", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").first();
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("bar")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("foo")).toBe(true);
});
it("Sorts according to admin", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(1);
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(false);
});
it("Sorts according to last activity", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(2);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo used most recently
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar used least recently
expect(first.html().includes("bar")).toBe(true);
});
it("Sorts according to server status (running/not running)", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(3);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo running
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar not running
expect(first.html().includes("bar")).toBe(true);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(serverDashboardJsx(jest.fn()));
expect(component.html()).toBe("<div></div>");
});
});

View File

@@ -1,32 +0,0 @@
@import url(../../style/root.css);
.server-dashboard-container {
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
.server-dashboard-container .add-users-button {
border: 1px solid #ddd;
}
.server-dashboard-container tbody {
color: #626262;
}
.admin-table-head {
user-select: none;
}
.sort-icon {
display: inline-block;
top: 0.125em;
position: relative;
user-select: none;
cursor: pointer;
}
tr.noborder > td {
border: none !important;
}

View File

@@ -1,35 +0,0 @@
:root {
--red: #d7191e;
--orange: #f1ad4e;
--blue: #2e7ab6;
--white: #ffffff;
--gray: #f7f7f7;
}
/* Color Classes */
.red {
background-color: var(--red);
}
.orange {
background-color: var(--orange);
}
.blue {
background-color: var(--blue);
}
.white {
background-color: var(--white);
}
/* Resets */
.resets .modal {
display: block;
visibility: visible;
z-index: 2000;
}
/* Global Util Classes */
.adjacent-span-spacing {
margin-right: 5px;
margin-left: 5px;
}

View File

@@ -1,10 +0,0 @@
export const jhapiRequest = (endpoint, method, data) => {
return fetch("/hub/api" + endpoint, {
method: method,
json: true,
headers: {
"Content-Type": "application/json",
},
body: data ? JSON.stringify(data) : null,
});
};

View File

@@ -1,23 +0,0 @@
export const timeSince = (time) => {
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
var elapsed = Date.now() - Date.parse(time);
if (elapsed < msPerMinute) {
return Math.round(elapsed / 1000) + " seconds ago";
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + " minutes ago";
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + " hours ago";
} else if (elapsed < msPerMonth) {
return Math.round(elapsed / msPerDay) + " days ago";
} else if (elapsed < msPerYear) {
return Math.round(elapsed / msPerMonth) + " months ago";
} else {
return Math.round(elapsed / msPerYear) + " years ago";
}
};

View File

@@ -1,52 +0,0 @@
import { withProps } from "recompose";
import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({
updateUsers: (offset, limit) =>
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
data.json()
),
updateGroups: (offset, limit) =>
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
(data) => data.json()
),
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
startAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
stopAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "DELETE")),
addToGroup: (users, groupname) =>
jhapiRequest("/groups/" + groupname + "/users", "POST", { users }),
removeFromGroup: (users, groupname) =>
jhapiRequest("/groups/" + groupname + "/users", "DELETE", { users }),
createGroup: (groupName) => jhapiRequest("/groups/" + groupName, "POST"),
deleteGroup: (name) => jhapiRequest("/groups/" + name, "DELETE"),
addUsers: (usernames, admin) =>
jhapiRequest("/users", "POST", { usernames, admin }),
editUser: (username, updated_username, admin) =>
jhapiRequest("/users/" + username, "PATCH", {
name: updated_username,
admin,
}),
deleteUser: (username) => jhapiRequest("/users/" + username, "DELETE"),
findUser: (username) => jhapiRequest("/users/" + username, "GET"),
validateUser: (username) =>
jhapiRequest("/users/" + username, "GET")
.then((data) => data.status)
.then((data) => (data > 200 ? false : true)),
failRegexEvent: () =>
alert(
"Cannot change username - either contains special characters or is too short."
),
noChangeEvent: () => {
returns;
},
refreshGroupsData: () =>
jhapiRequest("/groups", "GET").then((data) => data.json()),
refreshUserData: () =>
jhapiRequest("/users", "GET").then((data) => data.json()),
}));
export default withAPI;

View File

@@ -1,97 +0,0 @@
const webpack = require("webpack");
const path = require("path");
const express = require("express");
module.exports = {
entry: path.resolve(__dirname, "src", "App.jsx"),
mode: "production",
module: {
rules: [
{
test: /\.(js|jsx)/,
exclude: /node_modules/,
use: "babel-loader",
},
{
test: /\.(css)/,
exclude: /node_modules/,
use: ["style-loader", "css-loader"],
},
{
test: /\.(png|jpe?g|gif|svg|woff2?|ttf)$/i,
exclude: /node_modules/,
use: "file-loader",
},
],
},
output: {
publicPath: "/",
filename: "admin-react.js",
path: path.resolve(__dirname, "build"),
},
resolve: {
extensions: [".css", ".js", ".jsx"],
},
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: {
contentBase: path.resolve(__dirname, "build"),
port: 9000,
before: (app, server) => {
var user_data = JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
);
var group_data = JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
);
app.use(express.json());
// get user_data
app.get("/hub/api/users", (req, res) => {
res
.set("Content-Type", "application/json")
.send(JSON.stringify(user_data));
});
// get group_data
app.get("/hub/api/groups", (req, res) => {
res
.set("Content-Type", "application/json")
.send(JSON.stringify(group_data));
});
// add users to group
app.post("/hub/api/groups/*/users", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// remove users from group
app.delete("/hub/api/groups/*", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// add users
app.post("/hub/api/users", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// delete user
app.delete("/hub/api/users", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// start user server
app.post("/hub/api/users/*/server", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// stop user server
app.delete("/hub/api/users/*/server", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// shutdown hub
app.post("/hub/api/shutdown", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
},
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@
# Distributed under the terms of the Modified BSD License.
version_info = (
2,
1,
5,
0,
0,
"b2", # 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
)
@@ -46,12 +46,10 @@ def _check_version(hub_version, singleuser_version, log):
# compare minor X.Y versions
if hub_version != singleuser_version:
from packaging.version import parse
from distutils.version import LooseVersion as V
hub = parse(hub_version)
hub_major_minor = (hub.major, hub.minor)
singleuser = parse(singleuser_version)
singleuser_major_minor = (singleuser.major, singleuser.minor)
hub_major_minor = V(hub_version).version[:2]
singleuser_major_minor = V(singleuser_version).version[:2]
extra = ""
do_log = True
if singleuser_major_minor == hub_major_minor:
@@ -60,7 +58,7 @@ def _check_version(hub_version, singleuser_version, log):
log_method = log.debug
else:
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
key = f'{hub_version}-{singleuser_version}'
key = '%s-%s' % (hub_version, singleuser_version)
global _version_mismatch_warning_logged
if _version_mismatch_warning_logged.get(key):
do_log = False # We already logged this warning so don't log it again.

View File

@@ -1,104 +0,0 @@
"""
rbac changes for jupyterhub 2.0
Revision ID: 833da8570507
Revises: 4dc2d5a8c53c
Create Date: 2021-02-17 15:03:04.360368
"""
# revision identifiers, used by Alembic.
revision = '833da8570507'
down_revision = '4dc2d5a8c53c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from jupyterhub import orm
naming_convention = orm.meta.naming_convention
def upgrade():
# associate spawners and services with their oauth clients
# op.add_column(
# 'services', sa.Column('oauth_client_id', sa.Unicode(length=255), nullable=True)
# )
for table_name in ('services', 'spawners'):
column_name = "oauth_client_id"
target_table = "oauth_clients"
target_column = "identifier"
with op.batch_alter_table(
table_name,
schema=None,
) as batch_op:
batch_op.add_column(
sa.Column('oauth_client_id', sa.Unicode(length=255), nullable=True),
)
batch_op.create_foreign_key(
naming_convention["fk"]
% dict(
table_name=table_name,
column_0_name=column_name,
referred_table_name=target_table,
),
target_table,
[column_name],
[target_column],
ondelete='SET NULL',
)
# FIXME, maybe: currently drops all api tokens and forces recreation!
# this ensures a consistent database, but requires:
# 1. all servers to be stopped for upgrade (maybe unavoidable anyway)
# 2. any manually issued/stored tokens to be re-issued
# tokens loaded via configuration will be recreated on launch and unaffected
op.drop_table('api_tokens')
op.drop_table('oauth_access_tokens')
return
# TODO: explore in-place migration. This seems hard!
# 1. add new columns in api tokens
# 2. fill default fields (client_id='jupyterhub') for all api tokens
# 3. copy oauth tokens into api tokens
# 4. give oauth tokens 'identify' scopes
def downgrade():
for table_name in ('services', 'spawners'):
column_name = "oauth_client_id"
target_table = "oauth_clients"
target_column = "identifier"
with op.batch_alter_table(
table_name,
schema=None,
naming_convention=orm.meta.naming_convention,
) as batch_op:
batch_op.drop_constraint(
naming_convention["fk"]
% dict(
table_name=table_name,
column_0_name=column_name,
referred_table_name=target_table,
),
type_='foreignkey',
)
batch_op.drop_column(column_name)
# delete OAuth tokens for non-jupyterhub clients
# drop new columns from api tokens
# op.drop_constraint(None, 'api_tokens', type_='foreignkey')
# op.drop_column('api_tokens', 'session_id')
# op.drop_column('api_tokens', 'client_id')
# FIXME: only drop tokens whose client id is not 'jupyterhub'
# until then, drop all tokens
op.drop_table("api_tokens")
op.drop_table('api_token_role_map')
op.drop_table('service_role_map')
op.drop_table('user_role_map')
op.drop_table('roles')

View File

@@ -1,7 +1,6 @@
"""Authorization handlers"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import itertools
import json
from datetime import datetime
from urllib.parse import parse_qsl
@@ -14,8 +13,8 @@ from oauthlib import oauth2
from tornado import web
from .. import orm
from .. import roles
from .. import scopes
from ..user import User
from ..utils import compare_token
from ..utils import token_authenticated
from .base import APIHandler
from .base import BaseHandler
@@ -24,22 +23,12 @@ from .base import BaseHandler
class TokenAPIHandler(APIHandler):
@token_authenticated
def get(self, token):
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
# TODO: require specific scope for this deprecated API, applied to service tokens only?
self.log.warning(
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
)
orm_token = orm.APIToken.find(self.db, token)
if orm_token is None:
orm_token = orm.OAuthAccessToken.find(self.db, token)
if orm_token is None:
raise web.HTTPError(404)
owner = orm_token.user or orm_token.service
if owner:
# having a token means we should be able to read the owner's model
# (this is the only thing this handler is for)
self.expanded_scopes.update(scopes.identify_scopes(owner))
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
# record activity whenever we see a token
now = orm_token.last_activity = datetime.utcnow()
if orm_token.user:
@@ -56,20 +45,53 @@ class TokenAPIHandler(APIHandler):
self.write(json.dumps(model))
async def post(self):
raise web.HTTPError(
404,
"Deprecated endpoint /hub/api/authorizations/token is removed in JupyterHub 2.0."
" Use /hub/api/users/:user/tokens instead.",
warn_msg = (
"Using deprecated token creation endpoint %s."
" Use /hub/api/users/:user/tokens instead."
) % self.request.uri
self.log.warning(warn_msg)
requester = user = self.current_user
if user is None:
# allow requesting a token with username and password
# for authenticators where that's possible
data = self.get_json_body()
try:
requester = user = await self.login_user(data)
except Exception as e:
self.log.error("Failure trying to authenticate with form data: %s" % e)
user = None
if user is None:
raise web.HTTPError(403)
else:
data = self.get_json_body()
# admin users can request tokens for other users
if data and data.get('username'):
user = self.find_user(data['username'])
if user is not requester and not requester.admin:
raise web.HTTPError(
403, "Only admins can request tokens for other users."
)
if requester.admin and user is None:
raise web.HTTPError(400, "No such user '%s'" % data['username'])
note = (data or {}).get('note')
if not note:
note = "Requested via deprecated api"
if requester is not user:
kind = 'user' if isinstance(user, User) else 'service'
note += " by %s %s" % (kind, requester.name)
api_token = user.new_api_token(note=note)
self.write(
json.dumps(
{'token': api_token, 'warning': warn_msg, 'user': self.user_model(user)}
)
)
class CookieAPIHandler(APIHandler):
@token_authenticated
def get(self, cookie_name, cookie_value=None):
self.log.warning(
"/authorizations/cookie endpoint is deprecated in JupyterHub 2.0. Use /api/user with OAuth tokens."
)
cookie_name = quote(cookie_name, safe='')
if cookie_value is None:
self.log.warning(
@@ -176,23 +198,19 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
raise
self.send_oauth_response(headers, body, status)
def needs_oauth_confirm(self, user, oauth_client, roles):
def needs_oauth_confirm(self, user, oauth_client):
"""Return whether the given oauth client needs to prompt for access for the given user
Checks list for oauth clients that don't need confirmation
Sources:
- the user's own servers
- Clients which already have authorization for the same roles
- Explicit oauth_no_confirm_list configuration (e.g. admin-operated services)
(i.e. the user's own server)
.. versionadded: 1.1
"""
# get the oauth client ids for the user's own server(s)
own_oauth_client_ids = {
own_oauth_client_ids = set(
spawner.oauth_client_id for spawner in user.spawners.values()
}
)
if (
# it's the user's own server
oauth_client.identifier in own_oauth_client_ids
@@ -201,27 +219,6 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
in self.settings.get('oauth_no_confirm_list', set())
):
return False
# Check existing authorization
existing_tokens = self.db.query(orm.APIToken).filter_by(
user_id=user.id,
client_id=oauth_client.identifier,
)
authorized_roles = set()
for token in existing_tokens:
authorized_roles.update({role.name for role in token.roles})
if authorized_roles:
if set(roles).issubset(authorized_roles):
self.log.debug(
f"User {user.name} has already authorized {oauth_client.identifier} for roles {roles}"
)
return False
else:
self.log.debug(
f"User {user.name} has authorized {oauth_client.identifier}"
f" for roles {authorized_roles}, confirming additonal roles {roles}"
)
# default: require confirmation
return True
@@ -246,90 +243,28 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
uri, http_method, body, headers = self.extract_oauth_params()
try:
(
role_names,
credentials,
) = self.oauth_provider.validate_authorization_request(
scopes, credentials = self.oauth_provider.validate_authorization_request(
uri, http_method, body, headers
)
credentials = self.add_credentials(credentials)
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
allowed = False
# check for access to target resource
if client.spawner:
scope_filter = self.get_scope_filter("access:servers")
allowed = scope_filter(client.spawner, kind='server')
elif client.service:
scope_filter = self.get_scope_filter("access:services")
allowed = scope_filter(client.service, kind='service')
else:
# client is not associated with a service or spawner.
# This shouldn't happen, but it might if this is a stale or forged request
# from a service or spawner that's since been deleted
self.log.error(
f"OAuth client {client} has no service or spawner, cannot resolve scopes."
)
raise web.HTTPError(500, "OAuth configuration error")
if not allowed:
self.log.error(
f"User {self.current_user} not allowed to access {client.description}"
)
raise web.HTTPError(
403, f"You do not have permission to access {client.description}"
)
if not self.needs_oauth_confirm(self.current_user, client, role_names):
if not self.needs_oauth_confirm(self.current_user, client):
self.log.debug(
"Skipping oauth confirmation for %s accessing %s",
self.current_user,
client.description,
)
# this is the pre-1.0 behavior for all oauth
self._complete_login(uri, headers, role_names, credentials)
self._complete_login(uri, headers, scopes, credentials)
return
# resolve roles to scopes for authorization page
raw_scopes = set()
if role_names:
role_objects = (
self.db.query(orm.Role).filter(orm.Role.name.in_(role_names)).all()
)
raw_scopes = set(
itertools.chain(*(role.scopes for role in role_objects))
)
if not raw_scopes:
scope_descriptions = [
{
"scope": None,
"description": scopes.scope_definitions['(no_scope)'][
'description'
],
"filter": "",
}
]
elif 'all' in raw_scopes:
raw_scopes = ['all']
scope_descriptions = [
{
"scope": "all",
"description": scopes.scope_definitions['all']['description'],
"filter": "",
}
]
else:
scope_descriptions = scopes.describe_raw_scopes(
raw_scopes,
username=self.current_user.name,
)
# Render oauth 'Authorize application...' page
auth_state = await self.current_user.get_auth_state()
self.write(
await self.render_template(
"oauth.html",
auth_state=auth_state,
role_names=role_names,
scope_descriptions=scope_descriptions,
scopes=scopes,
oauth_client=client,
)
)

View File

@@ -2,12 +2,8 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from functools import lru_cache
from datetime import datetime
from http.client import responses
from urllib.parse import parse_qs
from urllib.parse import urlencode
from urllib.parse import urlparse
from urllib.parse import urlunparse
from sqlalchemy.exc import SQLAlchemyError
from tornado import web
@@ -17,8 +13,6 @@ from ..handlers import BaseHandler
from ..utils import isoformat
from ..utils import url_path_join
PAGINATION_MEDIA_TYPE = "application/jupyterhub-pagination+json"
class APIHandler(BaseHandler):
"""Base class for API endpoints
@@ -38,16 +32,6 @@ class APIHandler(BaseHandler):
def get_content_type(self):
return 'application/json'
@property
@lru_cache()
def accepts_pagination(self):
"""Return whether the client accepts the pagination preview media type"""
accept_header = self.request.headers.get("Accept", "")
if not accept_header:
return False
accepts = {s.strip().lower() for s in accept_header.strip().split(",")}
return PAGINATION_MEDIA_TYPE in accepts
def check_referer(self):
"""Check Origin for cross-site API requests.
@@ -78,39 +62,14 @@ class APIHandler(BaseHandler):
return False
return True
def check_post_content_type(self):
"""Check request content-type, e.g. for cross-site POST requests
Cross-site POST via form will include content-type
"""
content_type = self.request.headers.get("Content-Type")
if not content_type:
# not specified, e.g. from a script
return True
# parse content type for application/json
fields = content_type.lower().split(";")
if not any(f.lstrip().startswith("application/json") for f in fields):
self.log.warning(f"Not allowing POST with content-type: {content_type}")
return False
return True
def get_current_user_cookie(self):
"""Extend get_user_cookie to add checks for CORS"""
"""Override get_user_cookie to check Referer header"""
cookie_user = super().get_current_user_cookie()
# CORS checks for cookie-authentication
# check these only if there is a cookie user,
# check referer only if there is a cookie user,
# avoiding misleading "Blocking Cross Origin" messages
# when there's no cookie set anyway.
if cookie_user:
if not self.check_referer():
return None
if (
self.request.method.upper() == 'POST'
and not self.check_post_content_type()
):
return None
if cookie_user and not self.check_referer():
return None
return cookie_user
def get_json_body(self):
@@ -172,26 +131,36 @@ class APIHandler(BaseHandler):
json.dumps({'status': status_code, 'message': message or status_message})
)
def server_model(self, spawner):
"""Get the JSON model for a Spawner
Assume server permission already granted"""
model = {
def server_model(self, spawner, include_state=False):
"""Get the JSON model for a Spawner"""
return {
'name': spawner.name,
'last_activity': isoformat(spawner.orm_spawner.last_activity),
'started': isoformat(spawner.orm_spawner.started),
'pending': spawner.pending,
'ready': spawner.ready,
'state': spawner.get_state() if include_state else None,
'url': url_path_join(spawner.user.url, spawner.name, '/'),
'user_options': spawner.user_options,
'progress_url': spawner._progress_url,
}
scope_filter = self.get_scope_filter('admin:server_state')
if scope_filter(spawner, kind='server'):
model['state'] = spawner.get_state()
return model
def token_model(self, token):
"""Get the JSON model for an APIToken"""
expires_at = None
if isinstance(token, orm.APIToken):
kind = 'api_token'
extra = {'note': token.note}
expires_at = token.expires_at
elif isinstance(token, orm.OAuthAccessToken):
kind = 'oauth'
extra = {'oauth_client': token.client.description or token.client.client_id}
if token.expires_at:
expires_at = datetime.fromtimestamp(token.expires_at)
else:
raise TypeError(
"token must be an APIToken or OAuthAccessToken, not %s" % type(token)
)
if token.user:
owner_key = 'user'
@@ -204,146 +173,59 @@ class APIHandler(BaseHandler):
model = {
owner_key: owner,
'id': token.api_id,
'kind': 'api_token',
'roles': [r.name for r in token.roles],
'kind': kind,
'created': isoformat(token.created),
'last_activity': isoformat(token.last_activity),
'expires_at': isoformat(token.expires_at),
'note': token.note,
'oauth_client': token.oauth_client.description
or token.oauth_client.identifier,
'expires_at': isoformat(expires_at),
}
model.update(extra)
return model
def _filter_model(self, model, access_map, entity, kind, keys=None):
"""
Filter the model based on the available scopes and the entity requested for.
If keys is a dictionary, update it with the allowed keys for the model.
"""
allowed_keys = set()
for scope in access_map:
scope_filter = self.get_scope_filter(scope)
if scope_filter(entity, kind=kind):
allowed_keys |= access_map[scope]
model = {key: model[key] for key in allowed_keys if key in model}
if isinstance(keys, set):
keys.update(allowed_keys)
return model
def user_model(self, user):
def user_model(self, user, include_servers=False, include_state=False):
"""Get the JSON model for a User object"""
if isinstance(user, orm.User):
user = self.users[user.id]
model = {
'kind': 'user',
'name': user.name,
'admin': user.admin,
'roles': [r.name for r in user.roles],
'groups': [g.name for g in user.groups],
'server': user.url if user.running else None,
'pending': None,
'created': isoformat(user.created),
'last_activity': isoformat(user.last_activity),
'auth_state': None, # placeholder, filled in later
}
access_map = {
'read:users': {
'kind',
'name',
'admin',
'roles',
'groups',
'server',
'pending',
'created',
'last_activity',
},
'read:users:name': {'kind', 'name', 'admin'},
'read:users:groups': {'kind', 'name', 'groups'},
'read:users:activity': {'kind', 'name', 'last_activity'},
'read:servers': {'kind', 'name', 'servers'},
'read:roles:users': {'kind', 'name', 'roles', 'admin'},
'admin:auth_state': {'kind', 'name', 'auth_state'},
}
allowed_keys = set()
model = self._filter_model(
model, access_map, user, kind='user', keys=allowed_keys
)
if model:
if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending
if '' in user.spawners:
model['pending'] = user.spawners[''].pending
servers = model['servers'] = {}
scope_filter = self.get_scope_filter('read:servers')
for name, spawner in user.spawners.items():
# include 'active' servers, not just ready
# (this includes pending events)
if spawner.active and scope_filter(spawner, kind='server'):
servers[name] = self.server_model(spawner)
if not servers and 'servers' not in allowed_keys:
# omit servers if no access
# leave present and empty
# if request has access to read servers in general
model.pop('servers')
if not include_servers:
model['servers'] = None
return model
servers = model['servers'] = {}
for name, spawner in user.spawners.items():
# include 'active' servers, not just ready
# (this includes pending events)
if spawner.active:
servers[name] = self.server_model(spawner, include_state=include_state)
return model
def group_model(self, group):
"""Get the JSON model for a Group object"""
model = {
return {
'kind': 'group',
'name': group.name,
'roles': [r.name for r in group.roles],
'users': [u.name for u in group.users],
}
access_map = {
'read:groups': {'kind', 'name', 'users'},
'read:groups:name': {'kind', 'name'},
'read:roles:groups': {'kind', 'name', 'roles'},
}
model = self._filter_model(model, access_map, group, 'group')
return model
def service_model(self, service):
"""Get the JSON model for a Service object"""
model = {
'kind': 'service',
'name': service.name,
'roles': [r.name for r in service.roles],
'admin': service.admin,
'url': getattr(service, 'url', ''),
'prefix': service.server.base_url if getattr(service, 'server', '') else '',
'command': getattr(service, 'command', ''),
'pid': service.proc.pid if getattr(service, 'proc', '') else 0,
'info': getattr(service, 'info', ''),
'display': getattr(service, 'display', ''),
}
access_map = {
'read:services': {
'kind',
'name',
'admin',
'url',
'prefix',
'command',
'pid',
'info',
'display',
},
'read:services:name': {'kind', 'name', 'admin'},
'read:roles:services': {'kind', 'name', 'roles', 'admin'},
}
model = self._filter_model(model, access_map, service, 'service')
return model
return {'kind': 'service', 'name': service.name, 'admin': service.admin}
_user_model_types = {
'name': str,
'admin': bool,
'groups': list,
'roles': list,
'auth_state': dict,
}
_user_model_types = {'name': str, 'admin': bool, 'groups': list, 'auth_state': dict}
_group_model_types = {'name': str, 'users': list, 'roles': list}
_group_model_types = {'name': str, 'users': list}
def _check_model(self, model, model_types, name):
"""Check a model provided by a REST API request
@@ -383,67 +265,6 @@ class APIHandler(BaseHandler):
400, ("group names must be str, not %r", type(groupname))
)
def get_api_pagination(self):
default_limit = self.settings["api_page_default_limit"]
max_limit = self.settings["api_page_max_limit"]
if not self.accepts_pagination:
# if new pagination Accept header is not used,
# default to the higher max page limit to reduce likelihood
# of missing users due to pagination in code that hasn't been updated
default_limit = max_limit
offset = self.get_argument("offset", None)
limit = self.get_argument("limit", default_limit)
try:
offset = abs(int(offset)) if offset is not None else 0
limit = abs(int(limit))
if limit > max_limit:
limit = max_limit
if limit < 1:
limit = 1
except Exception as e:
raise web.HTTPError(
400, "Invalid argument type, offset and limit must be integers"
)
return offset, limit
def paginated_model(self, items, offset, limit, total_count):
"""Return the paginated form of a collection (list or dict)
A dict with { items: [], _pagination: {}}
instead of a single list (or dict).
pagination info includes the current offset and limit,
the total number of results for the query,
and information about how to build the next page request
if there is one.
"""
next_offset = offset + limit
data = {
"items": items,
"_pagination": {
"offset": offset,
"limit": limit,
"total": total_count,
"next": None,
},
}
if next_offset < total_count:
# if there's a next page
next_url_parsed = urlparse(self.request.full_url())
query = parse_qs(next_url_parsed.query)
query['offset'] = [next_offset]
query['limit'] = [limit]
next_url_parsed = next_url_parsed._replace(
query=urlencode(query, doseq=True)
)
next_url = urlunparse(next_url_parsed)
data["_pagination"]["next"] = {
"offset": next_offset,
"limit": limit,
"url": next_url,
}
return data
def options(self, *args, **kwargs):
self.finish()

View File

@@ -6,8 +6,7 @@ import json
from tornado import web
from .. import orm
from ..scopes import needs_scope
from ..scopes import Scope
from ..utils import admin_only
from .base import APIHandler
@@ -23,51 +22,27 @@ class _GroupAPIHandler(APIHandler):
users.append(user.orm_user)
return users
def find_group(self, group_name):
def find_group(self, name):
"""Find and return a group by name.
Raise 404 if not found.
"""
group = orm.Group.find(self.db, name=group_name)
group = orm.Group.find(self.db, name=name)
if group is None:
raise web.HTTPError(404, "No such group: %s", group_name)
raise web.HTTPError(404, "No such group: %s", name)
return group
class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('list:groups')
@admin_only
def get(self):
"""List groups"""
query = full_query = self.db.query(orm.Group)
sub_scope = self.parsed_scopes['list:groups']
if sub_scope != Scope.ALL:
if not set(sub_scope).issubset({'group'}):
# the only valid filter is group=...
# don't expand invalid !server=x to all groups!
self.log.warning(
"Invalid filter on list:group for {self.current_user}: {sub_scope}"
)
raise web.HTTPError(403)
query = query.filter(orm.Group.name.in_(sub_scope['group']))
offset, limit = self.get_api_pagination()
query = query.order_by(orm.Group.id.asc()).offset(offset).limit(limit)
group_list = [self.group_model(g) for g in query]
total_count = full_query.count()
if self.accepts_pagination:
data = self.paginated_model(group_list, offset, limit, total_count)
else:
query_count = query.count()
if offset == 0 and total_count > query_count:
self.log.warning(
f"Truncated group list in request that does not expect pagination. Replying with {query_count} of {total_count} total groups."
)
data = group_list
data = [self.group_model(g) for g in self.db.query(orm.Group)]
self.write(json.dumps(data))
@needs_scope('admin:groups')
@admin_only
async def post(self):
"""POST creates Multiple groups"""
"""POST creates Multiple groups """
model = self.get_json_body()
if not model or not isinstance(model, dict) or not model.get('groups'):
raise web.HTTPError(400, "Must specify at least one group to create")
@@ -98,13 +73,13 @@ class GroupListAPIHandler(_GroupAPIHandler):
class GroupAPIHandler(_GroupAPIHandler):
"""View and modify groups by name"""
@needs_scope('read:groups', 'read:groups:name', 'read:roles:groups')
def get(self, group_name):
group = self.find_group(group_name)
@admin_only
def get(self, name):
group = self.find_group(name)
self.write(json.dumps(self.group_model(group)))
@needs_scope('admin:groups')
async def post(self, group_name):
@admin_only
async def post(self, name):
"""POST creates a group by name"""
model = self.get_json_body()
if model is None:
@@ -112,28 +87,28 @@ class GroupAPIHandler(_GroupAPIHandler):
else:
self._check_group_model(model)
existing = orm.Group.find(self.db, name=group_name)
existing = orm.Group.find(self.db, name=name)
if existing is not None:
raise web.HTTPError(409, "Group %s already exists" % group_name)
raise web.HTTPError(409, "Group %s already exists" % name)
usernames = model.get('users', [])
# check that users exist
users = self._usernames_to_users(usernames)
# create the group
self.log.info("Creating new group %s with %i users", group_name, len(users))
self.log.info("Creating new group %s with %i users", name, len(users))
self.log.debug("Users: %s", usernames)
group = orm.Group(name=group_name, users=users)
group = orm.Group(name=name, users=users)
self.db.add(group)
self.db.commit()
self.write(json.dumps(self.group_model(group)))
self.set_status(201)
@needs_scope('delete:groups')
def delete(self, group_name):
@admin_only
def delete(self, name):
"""Delete a group by name"""
group = self.find_group(group_name)
self.log.info("Deleting group %s", group_name)
group = self.find_group(name)
self.log.info("Deleting group %s", name)
self.db.delete(group)
self.db.commit()
self.set_status(204)
@@ -142,41 +117,39 @@ class GroupAPIHandler(_GroupAPIHandler):
class GroupUsersAPIHandler(_GroupAPIHandler):
"""Modify a group's user list"""
@needs_scope('groups')
def post(self, group_name):
@admin_only
def post(self, name):
"""POST adds users to a group"""
group = self.find_group(group_name)
group = self.find_group(name)
data = self.get_json_body()
self._check_group_model(data)
if 'users' not in data:
raise web.HTTPError(400, "Must specify users to add")
self.log.info("Adding %i users to group %s", len(data['users']), group_name)
self.log.info("Adding %i users to group %s", len(data['users']), name)
self.log.debug("Adding: %s", data['users'])
for user in self._usernames_to_users(data['users']):
if user not in group.users:
group.users.append(user)
else:
self.log.warning("User %s already in group %s", user.name, group_name)
self.log.warning("User %s already in group %s", user.name, name)
self.db.commit()
self.write(json.dumps(self.group_model(group)))
@needs_scope('groups')
async def delete(self, group_name):
@admin_only
async def delete(self, name):
"""DELETE removes users from a group"""
group = self.find_group(group_name)
group = self.find_group(name)
data = self.get_json_body()
self._check_group_model(data)
if 'users' not in data:
raise web.HTTPError(400, "Must specify users to delete")
self.log.info("Removing %i users from group %s", len(data['users']), group_name)
self.log.info("Removing %i users from group %s", len(data['users']), name)
self.log.debug("Removing: %s", data['users'])
for user in self._usernames_to_users(data['users']):
if user in group.users:
group.users.remove(user)
else:
self.log.warning(
"User %s already not in group %s", user.name, group_name
)
self.log.warning("User %s already not in group %s", user.name, name)
self.db.commit()
self.write(json.dumps(self.group_model(group)))

Some files were not shown because too many files have changed in this diff Show More