Compare commits

...

314 Commits
2.2.2 ... 3.0.0

Author SHA1 Message Date
Min RK
24506888ea Bump to 3.0.0 2022-09-09 08:32:58 +02:00
Min RK
457ad3ec85 Merge pull request #4029 from consideRatio/pr/update-changelog-for-3.0.0
Update changelog for 3.0.0
2022-09-09 08:16:24 +02:00
Min RK
75e8274a7b date for 3.0.0 2022-09-09 08:16:07 +02:00
Erik Sundell
2a3ceff29f Update changelog for 3.0.0 2022-09-07 09:58:17 +02:00
Erik Sundell
5e29605341 Merge pull request #4018 from minrk/reset-offset
reset offset to 0 on name filter change
2022-09-05 18:46:08 +02:00
Min RK
34b6bc3a3f call reducers in some tests
allows testing reducer functionality

workaround bug preventing mocked useSelector from behaving realistically
2022-09-05 15:09:11 +02:00
Erik Sundell
7a48da1916 Merge pull request #4022 from possiblyMikeB/token-template-correction
Use correct expiration labels in drop-down menu on token page.
2022-08-24 20:01:20 +02:00
possiblyMikeB
5eaf59dd72 correct token expiration time labels 2022-08-23 18:29:15 -04:00
Simon Li
73a33ed5fc Merge pull request #4019 from minrk/sync-groups-repeat
avoid database error on repeated group name in sync_groups
2022-08-19 16:54:06 +01:00
Min RK
0b9ae96a96 avoid database error on repeated group name in sync_groups 2022-08-19 10:53:21 +02:00
Min RK
2c9653bc0d reset offset to 0 on name filter change
move offset to redux state, rather than independent,
since it can come from two places (user_page and pagination footer). Keeps things in sync.

Adds reducers for setting offset, name filter explicitly.
2022-08-19 10:25:17 +02:00
Erik Sundell
71e86f3064 Merge pull request #4016 from minrk/edituser-validate
admin: avoid redundant client-side username validation in edit-user
2022-08-16 14:13:49 +02:00
Min RK
8a1110f2c0 admin: avoid redundant client-side username validation
username validation is the server-side's responsibility
2022-08-16 13:48:36 +02:00
Min RK
bb52351a6e Merge pull request #4013 from minrk/test-311
Test 3.11
2022-08-10 11:36:14 +02:00
Min RK
87c745d3bf mock greenlet needs to raise ImportError 2022-08-10 11:02:53 +02:00
Min RK
374c6c848b it's actually greenlet 2022-08-10 10:45:57 +02:00
Min RK
af31ee8c94 condition brackets
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-08-10 10:28:30 +02:00
Min RK
26a9883b93 add mock-gevent to allow install on Python 3.11
gevent is not actually required, but sqlalchemy lists it as a dependency (on linux only)
2022-08-10 10:08:08 +02:00
Min RK
bda3e0c931 test on Python 3.11.0-rc.1 2022-08-10 10:06:37 +02:00
Min RK
f3d17eb77e Merge pull request #4012 from minrk/doc-oauth-no-confirm
document oauth_no_confirm in services
2022-08-10 09:30:48 +02:00
Erik Sundell
5f92cfcc0e Merge pull request #4011 from minrk/trim-form-input
restore trimming of username input
2022-08-10 09:05:34 +02:00
Min RK
b55eaae51f document oauth_no_confirm in services 2022-08-10 08:57:35 +02:00
Min RK
c9e6d6afa3 restore trimming of username input
continue to not trim password or custom fields

trailing/leading space is explicitly forbidden in validate_username
2022-08-10 08:45:50 +02:00
Erik Sundell
2f1d340c42 Merge pull request #4006 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-08-09 08:09:29 +02:00
pre-commit-ci[bot]
2ba99656c1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 5.0.2 → 5.0.4](https://github.com/PyCQA/flake8/compare/5.0.2...5.0.4)
2022-08-08 22:58:55 +00:00
Erik Sundell
635f63c1cd Merge pull request #4005 from jupyterhub/dependabot/github_actions/docker/build-push-action-3.1.1
Bump docker/build-push-action from 3.1.0 to 3.1.1
2022-08-08 09:50:23 +02:00
dependabot[bot]
b9b49ff306 Bump docker/build-push-action from 3.1.0 to 3.1.1
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](1cb9d22b93...c84f382811)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-08-08 05:29:25 +00:00
Min RK
5640a1506e Merge pull request #4002 from naatebarber/admin-with-pagination-api
Integrate Pagination API into Admin JSX
2022-08-05 12:07:12 +02:00
pre-commit-ci[bot]
4767cfa4e9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-08-04 14:18:27 +00:00
Nathan Barber
309d687c26 Update jsx/src/components/Groups/Groups.jsx
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-08-04 10:17:43 -04:00
Nathan Barber
df25c09962 Update jsx/src/components/PaginationFooter/PaginationFooter.jsx
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-08-04 10:17:35 -04:00
Nathan Barber
09d0909878 Update unit tests to spec 2022-08-03 12:50:29 -04:00
Nathan Barber
72db4624e0 Move user/group queries from app to component uE's 2022-08-03 12:28:05 -04:00
Nathan Barber
e9eca22e3b add useEffect, new pagination style 2022-08-03 12:18:28 -04:00
Nathan Barber
33d4f382d5 Use data.items to display users 2022-08-03 10:59:38 -04:00
Min RK
562a24b651 Bump to 3.0.0b1 2022-08-02 15:10:28 +02:00
Simon Li
9318eb3fb2 Merge pull request #3994 from minrk/3.0-changelog
3.0 changelog
2022-08-02 14:05:38 +01:00
Min RK
0590b76cd0 3.0: refresh changelog entries 2022-08-02 14:42:33 +02:00
Min RK
8aac18c96d More documentation for 3.0 role/scope changes
a few more outdated `versionchanged` notes
2022-08-02 14:39:03 +02:00
Erik Sundell
6a6b8567c0 Merge pull request #3909 from minrk/include_stopped_servers
include stopped servers in user model
2022-08-02 14:31:14 +02:00
Min RK
78438bdfcc Begin 3.0 changelog
had to manually remove already-backported PRs
2022-08-02 14:29:16 +02:00
Erik Sundell
2096c956db Merge pull request #3877 from minrk/oauth_config
store scopes on oauth clients, too
2022-08-02 14:12:15 +02:00
Min RK
dfc2d4d4f1 Merge remote-tracking branch 'origin/main' into oauth_config 2022-08-02 13:53:51 +02:00
Min RK
5f57b72d6e fix version change in comment now that it's 3.0 2022-08-02 13:37:33 +02:00
Min RK
6a470b44e7 explicitly support async oauth_client_allowed_scopes 2022-08-02 13:37:32 +02:00
Min RK
a35a2ec8b7 less space
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-08-02 13:34:42 +02:00
Min RK
978b71c8bb Merge pull request #4001 from minrk/info-table-sort
admin: format user/server-info tables
2022-08-02 13:18:06 +02:00
Min RK
f3b328a4d8 format user/server-info tables
- sort keys for consistent presentation
- use text list for roles, groups, which aren't well rendered by the table-formatter (number index isn't helpful)
- render timestamps
- leave empty name for default server, instead of '[MAIN]' which isn't terminology used anywhere else
2022-08-02 12:28:13 +02:00
Min RK
9da78880e6 Merge pull request #4000 from minrk/jsx-deps
[admin] update, clean jsx deps
2022-08-02 12:27:12 +02:00
Min RK
f2871cfc3c update to maintained recompose fork 2022-08-02 11:58:01 +02:00
Min RK
24efd12ab5 jsx: move dev dependencies to devDependencies
doesn't really make a difference since it's not a real package, but cleaner.

updates webpack-dev-server
2022-08-02 11:05:42 +02:00
Erik Sundell
9b2e6b1c1d Merge pull request #3992 from minrk/avoid-deprecated-clear-current-singleuser
Avoid IOLoop.current in singleuser mixins
2022-08-02 10:06:43 +02:00
Erik Sundell
9f62d83568 Merge pull request #3998 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-08-02 08:54:33 +02:00
pre-commit-ci[bot]
7eb3575502 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.37.2 → v2.37.3](https://github.com/asottile/pyupgrade/compare/v2.37.2...v2.37.3)
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.2](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.2)
2022-08-01 23:15:08 +00:00
Min RK
6afa0d6311 include stopped servers in user model
opt-in, behind ?include_stopped_servers

Adds `stopped` field to server model to more easily select stopped servers
2022-08-01 15:47:36 +02:00
Erik Sundell
d170601678 Merge pull request #3997 from minrk/inaccurate-cookie
Remove outdated cookie-secret note in security docs
2022-08-01 14:20:20 +02:00
Min RK
01a33d150f Remove outdated cookie-secret note in security docs
this was true when we used shared cookie auth long ago
2022-08-01 13:28:13 +02:00
Simon Li
28b11d2165 Merge pull request #3969 from consideRatio/pr/set-announcement-properly
Fix disabling of individual page template announcements
2022-07-30 17:38:47 +01:00
Erik Sundell
83b5e8f3da Only blank strings to disable a specific page's announcement
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-07-30 11:53:00 +02:00
Min RK
e4e4bf5ff4 next release is 3.0, not 2.4 2022-07-29 15:35:08 +02:00
Min RK
ab3a01b9f6 even latest jupyter-server requires current event loop
for nbconvert handler lock
2022-07-29 15:31:50 +02:00
Min RK
8548472b6e Avoid IOLoop.current in singleuser mixins
- define our own init_ioloop
- call it ASAP
- put init_httpserver in IOLoop.run_sync because instantiating a server accesses the current loop
- run cleanup via asyncio.run
2022-07-29 14:51:30 +02:00
Erik Sundell
ab776e3989 Merge pull request #3974 from minrk/avoid-deprecated-clear-current
Avoid deprecated 'IOLoop.current' method
2022-07-29 12:03:52 +02:00
Min RK
b0b7378e2b Avoid deprecated 'IOLoop.current' method
Deprecated in tornado 6.2, only access running loop from inside coroutines
2022-07-29 11:30:39 +02:00
Erik Sundell
75e03ef1d9 Merge pull request #3976 from minrk/bump-versions
Require Python 3.7
2022-07-29 10:42:15 +02:00
Min RK
986de0b5db use str-format for ssl.Purposes
rather than default, which is a weird repr
2022-07-29 09:26:21 +02:00
Min RK
6959c9dde3 Merge pull request #3985 from jupyterhub/dependabot/npm_and_yarn/jsx/terser-5.14.2
Bump terser from 5.12.1 to 5.14.2 in /jsx
2022-07-29 08:30:05 +02:00
Min RK
53087e50e4 Merge pull request #3989 from rpwagner/patch-1
bump moment.js 2.29.4
2022-07-29 08:27:46 +02:00
Erik Sundell
dee830a56f Merge pull request #3975 from minrk/dockerfile-base
Bump Dockerfile base image to 22.04
2022-07-27 20:38:42 +02:00
Rick Wagner
45179c53b7 bump moment.js 2.29.4 2022-07-26 10:21:55 -07:00
Erik Sundell
6c7cb65224 Merge pull request #3988 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-26 10:30:45 +02:00
pre-commit-ci[bot]
0038b3c2e8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.37.1 → v2.37.2](https://github.com/asottile/pyupgrade/compare/v2.37.1...v2.37.2)
2022-07-25 22:16:38 +00:00
Erik Sundell
17bb8a9ba4 Unpin Dockerfile's ubuntu base image 2022-07-25 15:27:50 +02:00
Erik Sundell
9756c13f13 Merge pull request #3987 from jupyterhub/dependabot/github_actions/docker/build-push-action-3.1.0
Bump docker/build-push-action from 3.0.0 to 3.1.0
2022-07-25 10:03:26 +02:00
dependabot[bot]
be84b06ca6 Bump docker/build-push-action from 3.0.0 to 3.1.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](e551b19e49...1cb9d22b93)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-25 05:30:44 +00:00
dependabot[bot]
3ee88d99da Bump terser from 5.12.1 to 5.14.2 in /jsx
Bumps [terser](https://github.com/terser/terser) from 5.12.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-20 22:58:57 +00:00
Erik Sundell
16b0a2ac3b Merge pull request #3978 from minrk/warn-stacklevel
Increase stacklevel for decorated warnings
2022-07-15 08:44:12 +02:00
Min RK
a82d2e4903 Merge pull request #3977 from minrk/unpin-nbclassic
unpin nbclassic
2022-07-14 21:16:54 -07:00
Min RK
cef241a80b test: handle possibility that notebook is unavailable
no longer a strict dependency of other test deps
2022-07-14 21:07:57 -07:00
Min RK
eab6a1a112 typo in jinja env name
use loop variable, not hardcoded string
2022-07-14 20:34:07 -07:00
Min RK
827bfc99ec Merge pull request #3962 from manics/ConfigurableHTTPProxy-log_level
Add ConfigurableHTTPProxy.log_level
2022-07-14 20:22:41 -07:00
Min RK
b5bd307999 urllib.quote doesn't escape ~ starting with Python 3.7
3.7 adds ~ to the 'unreserved' (always safe) set,
but it's not safe in domain names.
so do it ourselves. Formalize in a `_dns_quote` private function,
with notes about issues.

The only usernames that change in this PR are those containing `_` or `/`,
the latter of which would have failed.
2022-07-14 20:19:50 -07:00
Min RK
d02d029e88 adjust Python test order 2022-07-14 16:02:01 -07:00
Min RK
e41885c458 Increase stacklevel for decorated warnings
otherwise warning just shows as being triggered in the decorator function
2022-07-14 16:00:27 -07:00
Simon Li
78ac9946e3 Use CaselessStrEnum instead of Enum 2022-07-14 21:51:14 +01:00
Simon Li
bd8e8eaa09 Add ConfigurableHTTPProxy.log_level 2022-07-14 21:51:12 +01:00
Min RK
a2a01755ec simplify make_ssl_context
pass ssl.Purpose explicitly, deprecate verify/check_hostname

3.10 disallows 'purpose=SERVER_AUTH' from creating server sockets.
Instead:

- pass purpose directly
- always verify
- no need to set check_hostname, already covered by purpose defaults
2022-07-14 11:02:44 -07:00
Min RK
a593c6187f can't test 3.11 yet because greenlet cannot be installed
sqlalchemy requires greenlet on linux, even though we don't use it
2022-07-14 10:10:58 -07:00
Min RK
b159cbfeef unpin nbclassic
0.4.3 is out, see if it fixes things
2022-07-14 09:09:25 -07:00
Min RK
a7cced506b Remove 3.6 compatibility shims
- asyncio.all_tasks/current_task
- pytest-asyncio 0.17
- contextmanager.nullcontext
2022-07-14 09:05:01 -07:00
Min RK
e8469af763 fixup: remove redundant check 2022-07-14 09:05:01 -07:00
Min RK
d2c6b23bf9 update python versions in test matrix 2022-07-14 09:05:01 -07:00
Min RK
c657498d75 require Python 3.7
drops support for Python 3.6
2022-07-14 09:05:01 -07:00
Min RK
001d0c9af1 call legacy notebook matrix entry legacy_notebook
instead of nbclassic, which is explicitly classic nb ui on jupyter-server (the opposite of these tests)
2022-07-14 09:05:01 -07:00
Min RK
0d0368c042 Bump Dockerfile base image to 22.04 2022-07-14 09:03:53 -07:00
Min RK
6fdd0ff7c5 Merge pull request #3967 from minrk/validate-extra-routes
validate proxy.extra_routes
2022-07-14 09:01:12 -07:00
Min RK
dee671b640 Merge pull request #3883 from minrk/async-hub-auth
allow HubAuth to be async
2022-07-13 20:38:36 -07:00
Min RK
c289a422c3 validate proxy.extra_routes
- add trailing slash if missing, and warn
- raise if leading slash is wrong (must not be present with host routing, must be present otherwise)
2022-07-13 20:33:39 -07:00
Erik Sundell
fa47529cf1 Merge pull request #3971 from minrk/nbclassic-renamed
nbclassic extension name has been renamed
2022-07-12 08:06:29 +02:00
Erik Sundell
6769de5b01 Merge pull request #3970 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-12 08:03:49 +02:00
Min RK
c8a8892292 nbclassic extension name has been renamed
our patches to the jinja env need updating to find the new env
2022-07-11 20:37:29 -07:00
pre-commit-ci[bot]
273b25cb6f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.34.0 → v2.37.1](https://github.com/asottile/pyupgrade/compare/v2.34.0...v2.37.1)
2022-07-11 21:54:26 +00:00
Erik Sundell
71e06a4cd7 Merge pull request #3958 from minrk/autocomplete-annotation
add correct autocomplete fields for login form
2022-07-10 12:11:04 +02:00
Erik Sundell
827310aca6 Fix disabling of individual page template announcements 2022-07-10 12:05:48 +02:00
Min RK
b9c83cf7ab allow HubAuth to be async
Switches requests to tornado AsyncHTTPClient instead of requests

For backward-compatibility, use opt-in `sync=False` arg for all public methods that _may_ be async

When sync=True (default), async functions still used, but blocking via ThreadPool + asyncio run_until_complete
2022-07-09 16:45:41 -07:00
Min RK
8a44748324 Bump to 2.4.0.dev 2022-07-09 16:44:24 -07:00
Min RK
e4f469ef73 Merge pull request #3968 from minrk/test-nbclassic
pin nbclassic in dev requirements
2022-07-09 16:41:27 -07:00
Min RK
4cf4566fff pin nbclassic < 0.4
0.4 doesn't actually load at all
2022-07-09 10:05:15 -07:00
Min RK
55c866f340 note why we depend on nbclassic
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-07-08 12:15:41 -07:00
Min RK
fd550e223e add nbclassic to dev requirements
ensures `/tree/` handler is present
2022-07-08 10:32:13 -07:00
Min RK
225ace636a call client-allowed scopes JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES 2022-07-08 10:18:59 -07:00
Erik Sundell
ee4c8b835b Merge pull request #3964 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-05 10:56:02 +02:00
pre-commit-ci[bot]
f43ad0c176 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0)
2022-07-04 22:34:28 +00:00
Min RK
95de2618a3 Merge pull request #3960 from cqzlxl/cqzlxl-patch-1
Fix GET /api/proxy with pagination
2022-06-30 21:18:09 +02:00
Min RK
48c9f6ca50 Test proxy API endpoint with pagination 2022-06-30 12:09:20 -07:00
cqzlxl
2d56bb74eb FIX a bug
it's an obvious bug.
2022-06-27 22:42:33 +08:00
Min RK
42cc3cae8e add correct autocomplete fields for login form 2022-06-24 10:37:50 +02:00
Min RK
02da11e06e Merge pull request #3955 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-06-21 09:58:36 +02:00
pre-commit-ci[bot]
eb1061a910 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.6.2 → v2.7.1](https://github.com/pre-commit/mirrors-prettier/compare/v2.6.2...v2.7.1)
2022-06-20 21:02:45 +00:00
Min RK
d852d9e37c Merge pull request #3953 from silenius/freebsd_pw
FreeBSD, missing -n for pw useradd
2022-06-16 11:33:06 +02:00
Julien Cigar
1392aee195 -n is required 2022-06-15 17:23:13 +02:00
Erik Sundell
63b7defe1a Merge pull request #3950 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-06-14 00:43:29 +02:00
pre-commit-ci[bot]
00803f039a [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.32.1 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.32.1...v2.34.0)
- [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0)
2022-06-13 22:29:26 +00:00
Erik Sundell
2b1c246c13 Merge pull request #3947 from jupyterhub/dependabot/github_actions/char0n/swagger-editor-validate-8829b79e438e100191c1e6ec1519daf0b66fed34
Bump char0n/swagger-editor-validate from 182d1a5d26ff5c2f4f452c43bd55e2c7d8064003 to 1.3.1
2022-06-13 13:48:53 +02:00
Erik Sundell
4f6dd69cb1 Merge pull request #3949 from jupyterhub/dependabot/github_actions/actions/setup-python-4
Bump actions/setup-python from 2 to 4
2022-06-13 13:48:43 +02:00
Erik Sundell
4fde1d2b65 Apply suggestions from code review 2022-06-13 13:48:21 +02:00
Erik Sundell
ccceebe257 Merge pull request #3948 from jupyterhub/dependabot/github_actions/actions/upload-artifact-3
Bump actions/upload-artifact from 2 to 3
2022-06-13 13:47:09 +02:00
Erik Sundell
499dac9ee2 ci: fix typo in test-docs workflow triggers 2022-06-13 13:46:09 +02:00
Erik Sundell
1d26e61f7e Apply suggestions from code review 2022-06-13 13:46:09 +02:00
dependabot[bot]
c40e20a3e3 Bump actions/setup-python from 2 to 4
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 4.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:38:08 +00:00
dependabot[bot]
549b2b8e95 Bump actions/upload-artifact from 2 to 3
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:38:04 +00:00
Erik Sundell
15665c0363 Merge pull request #3944 from jupyterhub/dependabot/github_actions/actions/setup-node-3
Bump actions/setup-node from 1 to 3
2022-06-13 13:38:01 +02:00
dependabot[bot]
226f993e7d Bump char0n/swagger-editor-validate
Bumps [char0n/swagger-editor-validate](https://github.com/char0n/swagger-editor-validate) from 182d1a5d26ff5c2f4f452c43bd55e2c7d8064003 to 1.3.1. This release includes the previously tagged commit.
- [Release notes](https://github.com/char0n/swagger-editor-validate/releases)
- [Commits](182d1a5d26...8829b79e43)

---
updated-dependencies:
- dependency-name: char0n/swagger-editor-validate
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:38:00 +00:00
dependabot[bot]
9081265dab Bump actions/setup-node from 1 to 3
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 1 to 3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:37:44 +00:00
Erik Sundell
de14f18be8 Merge pull request #3946 from consideRatio/pr/weekly-05
ci: run dependabot updates weekly monday 05:00 UTC+0 time
2022-06-13 13:37:39 +02:00
Erik Sundell
da276f0c6b Merge pull request #3945 from jupyterhub/dependabot/github_actions/actions/checkout-3
Bump actions/checkout from 2 to 3
2022-06-13 13:36:59 +02:00
Erik Sundell
5a3c98a849 Merge pull request #3943 from jupyterhub/dependabot/github_actions/docker/setup-qemu-action-2
Bump docker/setup-qemu-action from 1.0.2 to 2
2022-06-13 13:35:53 +02:00
Erik Sundell
51fa0af3fe Merge pull request #3942 from jupyterhub/dependabot/github_actions/docker/setup-buildx-action-2
Bump docker/setup-buildx-action from 1.1.2 to 2
2022-06-13 13:35:37 +02:00
Erik Sundell
fcdce01ae6 Merge pull request #3941 from jupyterhub/dependabot/github_actions/docker/build-push-action-3
Bump docker/build-push-action from 2.4.0 to 3
2022-06-13 13:35:19 +02:00
dependabot[bot]
9af9a7bff7 Bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:36 +00:00
dependabot[bot]
1eef021704 Bump docker/setup-qemu-action from 1.0.2 to 2
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1.0.2 to 2.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](25f0500ff2...8b122486ce)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:27 +00:00
dependabot[bot]
a308a0c9b4 Bump docker/setup-buildx-action from 1.1.2 to 2
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1.1.2 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](2a4b53665e...dc7b9719a9)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:23 +00:00
dependabot[bot]
726b8243eb Bump docker/build-push-action from 2.4.0 to 3
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2.4.0 to 3.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](e1b7f96249...e551b19e49)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:17 +00:00
Erik Sundell
88cea51561 Run updates weekly monday 05:00 UTC+0 time 2022-06-13 13:32:12 +02:00
Erik Sundell
ec0bcb1f1b Merge pull request #3940 from turrisxyz/Dependabot-GitHub-Actions
chore: add dependabot config for github actions
2022-06-13 13:31:32 +02:00
naveen
2df1808c4e chore: Included githubactions in the dependabot config
This should help with keeping the GitHub actions updated on new releases. This will also help with keeping it secure.

Dependabot helps in keeping the supply chain secure https://docs.github.com/en/code-security/dependabot

GitHub actions up to date https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot

https://github.com/ossf/scorecard/blob/main/docs/checks.md#dependency-update-tool
Signed-off-by: naveen <172697+naveensrinivasan@users.noreply.github.com>
2022-06-13 01:30:37 +00:00
Erik Sundell
c85e90a71b Merge pull request #3939 from turrisxyz/Pinned-Dependencies-GitHub
chore: Set permissions for GitHub actions
2022-06-12 10:19:14 +02:00
naveen
1013a49db2 chore: Set permissions for GitHub actions
Restrict the GitHub token permissions only to the required ones; this way, even if the attackers will succeed in compromising your workflow, they won’t be able to do much.

- Included permissions for the action. https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions

https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs

[Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/)

Signed-off-by: naveen <172697+naveensrinivasan@users.noreply.github.com>
2022-06-12 00:30:04 +00:00
Erik Sundell
f6eec29aa2 Merge pull request #3937 from minrk/importlib
switch to importlib_metadata for entrypoints
2022-06-08 15:23:54 +02:00
Min RK
64b99d5587 switch to importlib_metadata for entrypoints
standalone entrypoints package is deprecated
now that similar functionality is in the stdlib

need importlib_metadata >= 3.6 backport on Python < 3.10
2022-06-08 15:14:50 +02:00
Erik Sundell
75b07fc0d6 Merge pull request #3936 from minrk/add-user-validate
admin: Hub is responsible for username validation
2022-06-08 14:55:52 +02:00
Erik Sundell
d64068da66 Merge pull request #3935 from minrk/spawn-page-url
admin: Fix spawn page link for default server
2022-06-08 14:51:26 +02:00
Min RK
62b38934e5 store scopes on oauth clients, too
rather than roles, matching tokens

because oauth clients are mostly involved with issuing tokens,
they don't have roles themselves (their owners do).

This deprecates the `oauth_roles` config on Spawners and Services, in favor of `oauth_allowed_scopes`.

The ambiguously named `oauth_scopes` is renamed to `oauth_access_scopes`.
2022-06-08 12:26:48 +02:00
Min RK
14d8e23135 trim user input forms 2022-06-08 12:09:11 +02:00
Min RK
0908a15848 Server is responsible for username validation
Don't reimplement in the client
2022-06-08 11:06:33 +02:00
Min RK
2e878fb5ca fix spawn page link 2022-06-08 10:48:04 +02:00
Min RK
62d24341ca fix static url in admin page 2022-06-08 10:47:14 +02:00
Yuvi Panda
f2085fdf0f Merge pull request #3931 from consideRatio/pr/add-changelog-to-main
Add changelog for 2.3.0 and 2.3.1
2022-06-06 19:53:56 +05:30
Erik Sundell
a19c211612 Add changelog for 2.3.1 2022-06-06 16:18:03 +02:00
Min RK
9bbcf594ea One more in the changelog 2022-06-06 16:17:55 +02:00
Min RK
da89155503 changelog for 2.3 2022-06-06 16:17:54 +02:00
Min RK
3b59c4861f Merge pull request #3904 from manics/named-servers-escape
Escape named server name
2022-06-03 17:09:58 +02:00
Min RK
6f5764fd3d Merge pull request #3921 from manics/pages-unreachable
pages.py: Remove unreachable code
2022-06-03 16:58:33 +02:00
Simon Li
3c059f3acf Need to escape URLs in spawn-pending too 2022-06-02 19:56:52 +01:00
Simon Li
3a022f1ae3 pages.py: Remove unreachable code 2022-06-02 19:13:25 +01:00
Min RK
049a59f2ed Merge pull request #3920 from jupyterhub/dependabot/npm_and_yarn/jsx/eventsource-1.1.1
Bump eventsource from 1.1.0 to 1.1.1 in /jsx
2022-06-02 09:51:36 +02:00
Min RK
ed9ea4e6cc Merge pull request #3914 from manics/setuppy-yarn-jsx
Build admin app in setup.py
2022-06-02 09:51:22 +02:00
dependabot[bot]
c415be2db3 Bump eventsource from 1.1.0 to 1.1.1 in /jsx
Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: eventsource
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-01 22:51:13 +00:00
Simon Li
2bc5061e22 Don't escape servername in json blobs 2022-06-01 22:21:00 +01:00
pre-commit-ci[bot]
cedf12baeb [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-06-01 20:28:55 +00:00
Simon Li
b403c41c15 Remove old comment, include description in servername error
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-06-01 21:28:24 +01:00
Simon Li
acd75d85c7 Move installed data file check to script 2022-06-01 20:44:40 +01:00
Simon Li
5e5dad9512 check sdist files in release workflow 2022-06-01 20:43:28 +01:00
Simon Li
95e343395d Ensure jsx is in sdist 2022-06-01 20:43:02 +01:00
Yuvi Panda
6a29e5193b Merge pull request #3919 from minrk/jupyter-server-templates
ensure custom template is loaded with jupyter-server notebook extension
2022-06-01 22:59:33 +05:30
Min RK
1cb7177597 ensure custom template is loaded with jupyter-server notebook extension
our patches to page.html didn't affect nbclassic,
which gets its own jinja environment

regression test included
2022-06-01 16:13:10 +02:00
Yuvi Panda
50e863ca52 Merge pull request #3910 from minrk/optimize-prefix-lookup
use equality to filter token prefixes
2022-06-01 19:05:45 +05:30
Yuvi Panda
8cdd7ca2d2 Merge pull request #3918 from minrk/default-url-priority
set default_url via config
2022-06-01 19:04:55 +05:30
Min RK
6fbf8411ec Merge pull request #3915 from manics/contrib-docs
Update Contributing documentation
2022-05-31 19:52:30 +02:00
Min RK
fa200fed98 set default_url via config
avoids accidental overrides of `@default('default_url')` in subclasses,
e.g. SingleUserLabApp
2022-05-31 17:05:58 +02:00
Simon Li
7d7d30bcae Don't build admin app on readthedocs 2022-05-29 19:23:57 +01:00
Simon Li
85a4bbc28e Update Contributing documentation
Adds yarn, moves most of CONTRIBUTING.md into https://jupyterhub.readthedocs.io/en/stable/contributing/index.html to reduce duplication
2022-05-29 19:11:57 +01:00
Simon Li
0b161627c2 yarn: allow jlpm to be used instead 2022-05-29 17:14:14 +01:00
Simon Li
36e7898ed4 Update CI so that setup.py can build admin app 2022-05-29 16:52:24 +01:00
Simon Li
3537722208 Include generated admin-react.js.LICENSE.txt 2022-05-29 16:52:24 +01:00
Simon Li
dfcaa29c8a Build react admin app in setup.py 2022-05-29 16:52:20 +01:00
Simon Li
92c6d69bc8 Remove share/jupyterhub/static/js/admin-react.js jsx/build 2022-05-29 16:12:29 +01:00
Simon Li
7b8a2ae57b Escape server-name in URLs returned by API 2022-05-27 23:06:55 +01:00
Simon Li
b444fe478c Ensure server-name is escaped in proxy add_route 2022-05-27 22:44:09 +01:00
Simon Li
50fb1a016c Move server-name / check to higher up, add test 2022-05-27 22:06:19 +01:00
Min RK
e229c63e11 use equality to filter token prefixes
otherwise, index isn't used

note: this means changing the token prefix size requires revoking all tokens,
where before only _increasing_ the token prefix size required doing that.
2022-05-25 15:54:34 +02:00
Erik Sundell
9649a57e34 Merge pull request #3908 from minrk/fail-fail-auth-state
allow auth_state_hook to halt spawn
2022-05-25 12:43:39 +02:00
Erik Sundell
ac85d63013 Merge pull request #3907 from minrk/bump-moment
bump moment.js 2.29.2
2022-05-25 12:39:34 +02:00
Min RK
4b2ba1f6c0 allow auth_state_hook to halt spawn
hooks prior to start should raise and stop the whole thing

only hooks during cleanup need to be passed over
2022-05-25 11:36:32 +02:00
Min RK
886d15b622 bump moment.js 2.29.2 2022-05-25 11:32:06 +02:00
Min RK
d517ce37e7 Merge pull request #3906 from fabianbaier/patch-1
Force add existing certificates
2022-05-25 11:23:58 +02:00
Min RK
85f0cec33e Merge pull request #3903 from manics/jupyter-troubleshoot
`jupyter troubleshooting` ➡️  `jupyter troubleshoot`
2022-05-25 11:18:35 +02:00
pre-commit-ci[bot]
5c37569b2a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-05-25 05:04:07 +00:00
Fabian Baier
956b96967e Update app.py to fix certipy.CertExistsError 2022-05-24 21:58:33 -07:00
Simon Li
f51faa25ed Ban / from server-name 2022-05-24 17:46:08 +01:00
Simon Li
aa8c85f404 Test explicitly escaped servername from client 2022-05-24 17:08:21 +01:00
Simon Li
98540f0f6d Need to escape server name in part of proxy 2022-05-24 16:56:15 +01:00
Simon Li
841c89769a Add test for special chars in named servers 2022-05-24 16:55:48 +01:00
Simon Li
84cb9761e8 Escape named servers when used in URL paths 2022-05-22 23:31:47 +01:00
Simon Li
178e340223 jupyter troubleshooting ➡️ jupyter troubleshoot 2022-05-22 15:18:42 +01:00
Min RK
b18a05c2c8 Merge pull request #3895 from yuvipanda/document-display
Document the 'display' attribute of services
2022-05-13 11:56:58 +02:00
Min RK
3466de1473 Merge pull request #3899 from manics/admin_access_deprecated
`admin_access` no longer works as it is overridden by RBAC scopes
2022-05-13 11:53:58 +02:00
Simon Li
4be4e41fa7 admin_access no longer works as it is overridden by RBAC scopes
The `admin_access` variable is still referenced elsewhere and I considered removing it, but I couldn't work out exactly how/if it's used so this is just a docs change for now.
2022-05-12 12:31:50 +01:00
YuviPanda
3264463366 Document the 'display' attribute of services
Ref https://github.com/2i2c-org/infrastructure/issues/1301
2022-05-11 19:04:58 +05:30
Simon Li
8252504dad Merge pull request #3885 from minrk/deprecate-db
Deprecate Authenticator.db, Spawner.db
2022-05-10 17:48:20 +01:00
Min RK
ac3ef1efc1 Deprecate Authenticator.db, Spawner.db
These objects should not access the shared db session;
add a warning pointing to Issue about their removal if it is accessed
2022-05-10 10:24:32 +02:00
Min RK
54cb259882 Merge pull request #3891 from bbrauns/main
remove apache NE flag as it prevents opening folders and renaming fil…
2022-05-10 10:22:01 +02:00
Min RK
04d0291fa0 Merge pull request #3889 from johnkpark/jp--remove-admin-table-head
admin: make user-info table selectable
2022-05-10 10:21:06 +02:00
Erik Sundell
c8d6700406 Merge pull request #3892 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-05-10 07:01:25 +02:00
pre-commit-ci[bot]
e61f2d74a8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.32.0 → v2.32.1](https://github.com/asottile/pyupgrade/compare/v2.32.0...v2.32.1)
2022-05-09 20:54:37 +00:00
Björn Braunschweig
a0b9a1fe86 remove apache NE flag as it prevents opening folders and renaming files with whitespace characters. apache returns Bad Request 2022-05-09 11:36:14 +02:00
John Park
27d2e95c43 add regeneratorRuntime back in 2022-05-06 14:03:05 -07:00
John Park
819e59292d yarn lint / place 2022-05-05 16:34:28 -07:00
John Park
f3ef16b948 remove admin-table-head class 2022-05-05 16:21:29 -07:00
Simon Li
5e1e44057d Merge pull request #3886 from minrk/cleanup-api-shutdown
Cleanup everything on API shutdown
2022-05-05 20:16:43 +01:00
Min RK
bf2e322c22 Cleanup everything on API shutdown
via app.stop()
2022-05-05 12:53:21 +02:00
Min RK
585b47051f Merge pull request #3882 from yuvipanda/mo-exceptions
Use log.exception when logging exceptions
2022-05-05 11:37:42 +02:00
Min RK
5ca96fa758 Merge pull request #3878 from minrk/admin-ui-scope
add 'admin-ui' scope for access to the admin ui
2022-05-03 11:20:23 +02:00
YuviPanda
aba6eb962f Use log.exception when logging exceptions
This provides the stack trace in the log file, incredibly
useful when debugging
2022-05-02 17:36:31 -07:00
Georgiana Elena
107dc02fd0 Merge pull request #3876 from minrk/disallow-next-check
don't confuse :// in next_url query params for a redirect hostname
2022-04-29 13:13:21 +03:00
Min RK
debac715bf add 'admin-ui' scope for access to the admin ui 2022-04-29 11:54:02 +02:00
Min RK
c6ed41e322 don't confuse :// in next_url query params for a redirect hostname 2022-04-28 13:35:37 +02:00
Min RK
ec2c90c73f Merge pull request #3874 from code-review-doctor/fix-probably-meant-fstring
Missing `f` prefix on f-strings fix
2022-04-26 15:15:55 +02:00
Min RK
6c2c5e5a90 Don't let spawner._log_name fail when running without user in tests 2022-04-26 14:27:39 +02:00
code-review-doctor
f0b2d8c4eb Fix issue probably-meant-fstring found at https://codereview.doctor 2022-04-24 17:30:49 +01:00
Min RK
a588a0bfa3 Merge pull request #3851 from minrk/service-filter
!service and !server filters
2022-04-21 12:32:31 +02:00
Simon Li
c07358a526 Merge pull request #3869 from jupyterhub/dependabot/npm_and_yarn/jsx/async-2.6.4
Bump async from 2.6.3 to 2.6.4 in /jsx
2022-04-15 21:06:19 +01:00
dependabot[bot]
9058fa42dd Bump async from 2.6.3 to 2.6.4 in /jsx
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-15 19:08:43 +00:00
Min RK
55d7ebe006 Merge pull request #3867 from consideRatio/pr/black-detail
ci: update black configuration
2022-04-15 21:08:07 +02:00
Erik Sundell
6edbfdad89 ci: update black configuration 2022-04-15 19:16:51 +02:00
Erik Sundell
715a4a25cf Merge pull request #3864 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-04-12 11:04:12 +01:00
pre-commit-ci[bot]
e15447c8b8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.31.1 → v2.32.0](https://github.com/asottile/pyupgrade/compare/v2.31.1...v2.32.0)
- [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0)
2022-04-11 21:25:48 +00:00
Min RK
ab8eec164c Merge pull request #3863 from NarekA/narek/fix-search-bar
[Bug Fix] Search bar disabled on admin dashboard
2022-04-11 11:15:05 +02:00
Narek Amirbekian
b1177cd2ce Improve user search tests 2022-04-08 12:23:27 -07:00
Narek Amirbekian
40d95dc142 Fix search bar in admin dashboard 2022-04-08 11:27:41 -07:00
Min RK
d78bd42cfc call owner_name user_name since it's only defined when the owner is a user 2022-04-08 20:17:12 +02:00
Min RK
b6210dc225 add !service and !server scope filters
allows oauth clients to issue scopes that only grant access to the issuing service

e.g. access:service!service or access:servers!server

especially useful with custom scopes
2022-04-08 20:10:23 +02:00
Erik Sundell
b05a89a3e0 Merge pull request #3862 from cmd-ntrf/readme-rest-api
Fix typo in [rest api] link in README.md
2022-04-08 17:21:31 +02:00
Félix-Antoine Fortin
13e99b904b Fix typo in [rest api] link 2022-04-08 09:56:18 -04:00
Simon Li
01a4b9c4b4 Merge pull request #3859 from minrk/get_env_modify
Do not store Spawner.ip/port on spawner.server during get_env
2022-04-07 22:03:14 +01:00
Min RK
d6df1be272 Merge pull request #3850 from minrk/cache-scopes
memoize some scope functions
2022-04-07 12:56:19 +02:00
Min RK
85ef5cf807 Do not store Spawner.ip/port on spawner.server
This was part of an attempt to get the url from self.server.bind_url that didn't end up getting used

shouldn't mutate db state when getting the environment
2022-04-07 10:56:49 +02:00
Min RK
ff020cb5a4 needs_db typo
Co-authored-by: Simon Li <orpheus+devel@gmail.com>
2022-04-07 09:42:25 +02:00
Min RK
5c54ac9aa1 Merge pull request #3853 from jwclark/patch-1
Fix xsrf_cookie_kwargs ValueError
2022-04-05 20:28:03 +02:00
Erik Sundell
e48662423a Merge pull request #3856 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-04-05 01:00:08 +02:00
pre-commit-ci[bot]
f124f06c2d [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0)
- [github.com/pre-commit/mirrors-prettier: v2.6.1 → v2.6.2](https://github.com/pre-commit/mirrors-prettier/compare/v2.6.1...v2.6.2)
2022-04-04 20:23:56 +00:00
Joseph Clark
f2faf0ee43 Fix ValueError
Fixes ValueError: too many values to unpack (expected 2)
2022-04-01 15:44:28 -06:00
Min RK
ab2913008e more docs, comments, asserts about immutable scope functions 2022-04-01 11:54:32 +02:00
Min RK
eebc0f485d Apply suggestions from code review
Co-authored-by: Simon Li <orpheus+devel@gmail.com>
2022-04-01 11:36:06 +02:00
Min RK
bb6427ea9b Add FrozenDict for caching parsed_scopes dicts
Since we need them to be immutable
2022-04-01 11:36:05 +02:00
Min RK
29b73563dc cache common scope operations
we expand/parse the same scopes _a lot_.
We can save time with some caching.

Main change: cached functions must return immutable frozenset instead of mutable set,
to avoid mutating the result of subsequent returns.

Some functions can only be cached _sometimes_ (e.g. group lookups in db cannot be cached),
for which we have a DoNotCache(result) exception
2022-04-01 11:35:05 +02:00
Erik Sundell
aa0ce1c88a Merge pull request #3852 from minrk/isort
Use isort for import formatting
2022-04-01 11:07:28 +02:00
Min RK
7a9778249f run pre-commit with isort 2022-03-31 12:33:26 +02:00
Min RK
c41b732fbd switch to isort for import formatting
isort produces nicer imports without wasting a huge amount of space
2022-03-31 12:32:11 +02:00
Erik Sundell
d9b85a819e Merge pull request #3849 from huage1994/patch-1
The word `used` is duplicated in upgrade.md
2022-03-30 08:26:08 +02:00
nihua
6d00eb501a Update upgrade.md 2022-03-30 14:20:30 +08:00
Erik Sundell
318c95342d Merge pull request #3833 from minrk/token-scopes
Tokens have scopes instead of roles
2022-03-29 23:49:35 +02:00
Erik Sundell
cde0f12f07 Merge pull request #3848 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-28 22:41:22 +02:00
pre-commit-ci[bot]
6668fb39f9 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.6.0 → v2.6.1](https://github.com/pre-commit/mirrors-prettier/compare/v2.6.0...v2.6.1)
2022-03-28 19:36:33 +00:00
Min RK
4691fae90a Merge pull request #3845 from jupyterhub/dependabot/npm_and_yarn/jsx/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6 in /jsx
2022-03-28 09:03:51 +02:00
Min RK
0fccbc69ff Merge pull request #3834 from NarekA/narek/server-details-in-dashboard
Admin Dashboard - Collapsible Details View
2022-03-28 09:03:26 +02:00
dependabot[bot]
d699f794ac Bump minimist from 1.2.5 to 1.2.6 in /jsx
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-26 13:42:02 +00:00
Min RK
29a9ca18fe Merge pull request #3841 from minrk/asyncio-mode
adopt pytest-asyncio asyncio_mode='auto'
2022-03-26 14:41:29 +01:00
Erik Sundell
72ae21d6dc Merge pull request #3843 from minrk/doc-typos
Some typos in docs
2022-03-24 16:25:47 +01:00
Min RK
310d9621e5 limit server->read:users:name filter to read: scopes
it shouldn't be included in _access_ scopes, for instance
2022-03-24 16:13:57 +01:00
Min RK
0f4258d00c update more test expectations 2022-03-24 15:47:02 +01:00
Min RK
78b5aa150c avoid always-adding identify scope to everything
add it to token permissions _before_ intersecting with owner
2022-03-24 15:36:56 +01:00
Min RK
3cfb14b9e5 rerender rest-api 2022-03-24 15:16:21 +01:00
Min RK
7e22614a4e [squash me] token progress
tokens have scopes

    instead of roles, which allow tokens to change permissions over time

    This is mostly a low-level change,
    with little outward-facing effects.

    - on upgrade, evaluate all token role assignments to their current scopes,
      and store those scopes on the tokens
    - assigning roles to tokens still works, but scopes are evaluated and validated immediately,
      rather than lazily stored as roles
    - no longer need to check for role permission changes on startup, because token permissions aren't affected
    - move a few scope utilities from roles to scopes
    - oauth allows specifying scopes, not just roles.
      But these are still at the level specified in roles,
      not fully-resolved scopes.
    - more granular APIs for working with scopes and roles

    Still to do later:

    - expose scopes config for Spawner/service
    - compute 'full' intersection of requested scopes, rather than on the 'raw' scope list in roles
2022-03-24 15:05:50 +01:00
Min RK
66ecaf472a fix some outdated references to 'all' metascope
it is called 'inherit', but not all docs were updated
2022-03-24 14:06:05 +01:00
Min RK
3ba262f6f6 fix heading level in changelog
sphinx has started to error with this
2022-03-24 14:06:04 +01:00
Min RK
b935190da8 adopt pytest-asyncio asyncio_mode
removes need for our own implementation of the same behavior

but keep it around while we still support Python 3.6,
since the version (0.17) introducing asyncio_mode drops support for Python 3.6
2022-03-23 09:25:22 +01:00
Erik Sundell
7cd5c1c12b Merge pull request #3840 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-22 06:35:53 +01:00
pre-commit-ci[bot]
4708fce4f8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.5.1 → v2.6.0](https://github.com/pre-commit/mirrors-prettier/compare/v2.5.1...v2.6.0)
2022-03-21 23:39:32 +00:00
Narek Amirbekian
93fda7c96b Change layout 2022-03-21 13:05:56 -07:00
Erik Sundell
912e0ad53f Merge pull request #3839 from yuvipanda/log-docs-1
Document version mismatch log message
2022-03-21 11:40:49 +01:00
YuviPanda
3e9ce8bc03 Document version mismatch log message 2022-03-19 14:10:24 -07:00
Min RK
a08aa3398c ensure literal_binds is set in order
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-03-18 15:25:46 +01:00
Min RK
3076845927 tokens have scopes
instead of roles, which allow tokens to change permissions over time

This is mostly a low-level change,
with little outward-facing effects.

- on upgrade, evaluate all token role assignments to their current scopes,
  and store those scopes on the tokens
- assigning roles to tokens still works, but scopes are evaluated and validated immediately,
  rather than lazily stored as roles
- no longer need to check for role permission changes on startup, because token permissions aren't affected
- move a few scope utilities from roles to scopes
- oauth allows specifying scopes, not just roles.
  But these are still at the level specified in roles,
  not fully-resolved scopes.
- more granular APIs for working with scopes and roles
2022-03-18 14:13:16 +01:00
Erik Sundell
cb25d29b0b Merge pull request #3837 from minrk/fix-import-error
ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable
2022-03-18 10:03:18 +01:00
Min RK
2e8d303ad8 ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable 2022-03-18 09:24:22 +01:00
Erik Sundell
a754d56433 Merge pull request #3835 from minrk/rm-distutils
remove lingering reference to distutils
2022-03-17 13:28:43 +01:00
Min RK
775a16dc50 remove lingering reference to distutils 2022-03-17 12:16:44 +01:00
Narek Amirbekian
16824dcadb Use .toHaveClass instead of .contains 2022-03-16 16:42:17 -07:00
Narek Amirbekian
f949cda227 Add test for details view 2022-03-16 16:36:34 -07:00
Erik Sundell
454e356e4d Merge pull request #3713 from minrk/custom-scopes
allow user-defined custom scopes
2022-03-16 08:52:55 +01:00
Min RK
9a87b59e84 improve custom scope docstrings 2022-03-16 08:44:52 +01:00
Narek Amirbekian
93d82a9012 Fix tests 2022-03-15 17:09:26 -07:00
Narek Amirbekian
564458b106 Set defaults for name_filter 2022-03-15 15:13:04 -07:00
Narek Amirbekian
b38e9c45bf Improved layout 2022-03-15 13:40:44 -07:00
Narek Amirbekian
85d4c5bd7a Remove unused state object 2022-03-15 12:14:38 -07:00
Narek Amirbekian
6a9d27ceb4 Server details in server dashboard 2022-03-15 12:01:22 -07:00
Min RK
d2eaf90df2 authorize subsets of roles
- oauth clients can request a list of roles
- authorization will proceed with the _subset_ of those roles held by the user
- in the future, this subsetting will be refined to the scope level
2022-03-15 11:54:42 +01:00
Min RK
fa8cd90793 Merge pull request #3827 from NarekA/narek/admin-dashboard-search
[Admin Dash] Add search bar for user name
2022-03-15 11:35:57 +01:00
Narek Amirbekian
7dafae29fb Update compiled files 2022-03-15 02:40:27 -07:00
Narek Amirbekian
89a6c745b5 Add base_url to spawner 2022-03-15 02:33:17 -07:00
Erik Sundell
821d9e229d Merge pull request #3831 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-15 07:10:27 +01:00
Narek Amirbekian
db7619fa7a Fix server url 2022-03-14 21:02:18 -07:00
Narek Amirbekian
1ed9423530 Update compiled jsx 2022-03-14 18:06:10 -07:00
Narek Amirbekian
147a578f7a Fix index error on assertion 2022-03-14 18:03:56 -07:00
Narek Amirbekian
3a59a15164 Add front end tests for user search 2022-03-14 17:54:51 -07:00
pre-commit-ci[bot]
1b7aded7f9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-14 23:25:52 +00:00
pre-commit-ci[bot]
bc45d77365 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1)
- [github.com/asottile/reorder_python_imports: v2.7.1 → v3.0.1](https://github.com/asottile/reorder_python_imports/compare/v2.7.1...v3.0.1)
2022-03-14 23:24:13 +00:00
Narek Amirbekian
1b3b005ca4 Add test for name_filter 2022-03-14 13:33:05 -07:00
pre-commit-ci[bot]
e0be811b2c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-14 19:17:28 +00:00
Narek Amirbekian
3627251246 Merge branch 'main' into narek/admin-dashboard-search 2022-03-14 12:16:50 -07:00
Erik Sundell
8d056170d7 Bump to 2.3.0.dev 2022-03-14 12:32:56 +01:00
Narek Amirbekian
b3f04e7c66 Add search bar for user name 2022-03-11 15:12:53 -08:00
Min RK
fdf23600c0 allow custom scopes
defined with

    c.JupyterHub.custom_scopes = {
        'custom:scope': {'description': "text shown on oauth confirm"}
    }

Allows injecting custom scopes to roles,
allowing extension of granular permissions to service-defined custom scopes.

Custom scopes:

- MUST start with `custom:`
- MUST only contain ascii lowercase, numbers, colon, hyphen, asterisk, underscore
- MUST define a `description`
- MAY also define `subscopes` list(s), each of which must also be explicitly defined

HubAuth can be used to retrieve and check for custom scopes to authorize requests.
2022-03-11 11:37:26 +01:00
161 changed files with 7835 additions and 5837 deletions

15
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# dependabot.yml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
#
# Notes:
# - Status and logs from dependabot are provided at
# https://github.com/jupyterhub/jupyterhub/network/updates.
#
version: 2
updates:
# Maintain dependencies in our GitHub Workflows
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
time: "05:00"
timezone: "Etc/UTC"

View File

@@ -32,17 +32,18 @@ jobs:
build-release:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: "3.9"
- uses: actions/setup-node@v1
- uses: actions/setup-node@v3
with:
node-version: "14"
- name: install build package
- name: install build requirements
run: |
npm install -g yarn
pip install --upgrade pip
pip install build
pip freeze
@@ -52,28 +53,17 @@ jobs:
python -m build --sdist --wheel .
ls -l dist
- name: verify wheel
- name: verify sdist
run: |
cd dist
pip install ./*.whl
# verify data-files are installed where they are found
cat <<EOF | python
import os
from jupyterhub._data import DATA_FILES_PATH
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
for subpath in (
"templates/page.html",
"static/css/style.min.css",
"static/components/jquery/dist/jquery.js",
):
path = os.path.join(DATA_FILES_PATH, subpath)
assert os.path.exists(path), path
print("OK")
EOF
./ci/check_sdist.py dist/jupyterhub-*.tar.gz
- name: verify data-files are installed where they are found
run: |
pip install dist/*.whl
./ci/check_installed_data.py
# ref: https://github.com/actions/upload-artifact#readme
- uses: actions/upload-artifact@v2
- uses: actions/upload-artifact@v3
with:
name: jupyterhub-${{ github.sha }}
path: "dist/*"
@@ -108,16 +98,16 @@ jobs:
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
- uses: actions/checkout@v3
# Setup docker to build for multiple platforms, see:
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 # associated tag: v1.0.2
- name: Set up Docker Buildx (for multi-arch builds)
uses: docker/setup-buildx-action@2a4b53665e15ce7d7049afb11ff1f70ff1610609 # associated tag: v1.1.2
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 # associated tag: v1.1.2
with:
# Allows pushing to registry on localhost:5000
driver-opts: network=host
@@ -155,7 +145,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -176,7 +166,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
@@ -197,7 +187,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
@@ -221,7 +211,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub/singleuser
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
with:
build-args: |
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}

View File

@@ -15,15 +15,13 @@ on:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/*"
- "!.github/workflows/test-docs.yml"
- ".github/workflows/test-docs.yml"
push:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/*"
- "!.github/workflows/test-docs.yml"
- ".github/workflows/test-docs.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
@@ -40,18 +38,18 @@ jobs:
validate-rest-api-definition:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Validate REST API definition
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
uses: char0n/swagger-editor-validate@v1.3.1
with:
definition-file: docs/source/_static/rest-api.yml
test-docs:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.9"

View File

@@ -19,6 +19,9 @@ on:
- "**"
workflow_dispatch:
permissions:
contents: read
jobs:
# The ./jsx folder contains React based source code files that are to compile
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
@@ -29,8 +32,8 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "14"
@@ -47,62 +50,3 @@ jobs:
run: |
cd jsx
yarn test
# The ./jsx folder contains React based source files that are to compile to
# share/jupyterhub/static/js/admin-react.js. This job makes sure that whatever
# we have in jsx/src matches the compiled asset that we package and
# distribute.
#
# This job's purpose is to make sure we don't forget to compile changes and to
# verify nobody sneaks in a change in the hard to review compiled asset.
#
# NOTE: In the future we may want to stop version controlling the compiled
# artifact and instead generate it whenever we package JupyterHub. If we
# do this, we are required to setup node and compile the source code
# more often, at the same time we could avoid having this check be made.
#
compile-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn build
run: |
cd jsx
yarn build
- name: yarn place
run: |
cd jsx
yarn place
- name: Verify compiled jsx/src matches version controlled artifact
run: |
if [[ `git status --porcelain=v1` ]]; then
echo "The source code in ./jsx compiles to something different than found in ./share/jupyterhub/static/js/admin-react.js!"
echo
echo "Please re-compile the source code in ./jsx with the following commands:"
echo
echo "yarn"
echo "yarn build"
echo "yarn place"
echo
echo "See ./jsx/README.md for more details."
exit 1
else
echo "Compilation of jsx/src to share/jupyterhub/static/js/admin-react.js didn't lead to changes."
fi

View File

@@ -30,6 +30,9 @@ env:
LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
permissions:
contents: read
jobs:
# Run "pytest jupyterhub/tests" in various configurations
pytest:
@@ -53,9 +56,9 @@ jobs:
# Tests everything when JupyterHub works against a dedicated mysql or
# postgresql server.
#
# nbclassic:
# legacy_notebook:
# Tests everything when the user instances are started with
# notebook instead of jupyter_server.
# the legacy notebook server instead of jupyter_server.
#
# ssl:
# Tests everything using internal SSL connections instead of
@@ -68,21 +71,24 @@ jobs:
# NOTE: Since only the value of these parameters are presented in the
# GitHub UI when the workflow run, we avoid using true/false as
# values by instead duplicating the name to signal true.
# Python versions available at:
# https://github.com/actions/python-versions/blob/HEAD/versions-manifest.json
include:
- python: "3.6"
- python: "3.7"
oldest_dependencies: oldest_dependencies
nbclassic: nbclassic
- python: "3.6"
subdomain: subdomain
- python: "3.7"
db: mysql
- python: "3.7"
ssl: ssl
legacy_notebook: legacy_notebook
- python: "3.8"
db: postgres
- python: "3.8"
nbclassic: nbclassic
legacy_notebook: legacy_notebook
- python: "3.9"
db: mysql
- python: "3.10"
db: postgres
- python: "3.10"
subdomain: subdomain
- python: "3.10"
ssl: ssl
- python: "3.11.0-rc.1"
- python: "3.10"
main_dependencies: main_dependencies
steps:
@@ -110,28 +116,38 @@ jobs:
if [ "${{ matrix.jupyter_server }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
- uses: actions/checkout@v3
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node v14
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: "14"
- name: Install Node dependencies
- name: Install Javascript dependencies
run: |
npm install
npm install -g configurable-http-proxy
npm install -g configurable-http-proxy yarn
npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
# NOTE: actions/setup-python@v4 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Python ${{ matrix.python }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}
python-version: "${{ matrix.python }}"
- name: Install Python dependencies
run: |
pip install --upgrade pip
if [[ "${{ matrix.python }}" == "3.11"* ]]; then
# greenlet is not actually required,
# but is an install dependency of sqlalchemy.
# It does not yet install on 3.11
# see: see https://github.com/gevent/gevent/issues/1867
pip install ./ci/mock-greenlet
fi
pip install --upgrade . -r dev-requirements.txt
if [ "${{ matrix.oldest_dependencies }}" != "" ]; then
@@ -145,9 +161,9 @@ jobs:
if [ "${{ matrix.main_dependencies }}" != "" ]; then
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
fi
if [ "${{ matrix.nbclassic }}" != "" ]; then
if [ "${{ matrix.legacy_notebook }}" != "" ]; then
pip uninstall jupyter_server --yes
pip install notebook
pip install 'notebook<7'
fi
if [ "${{ matrix.db }}" == "mysql" ]; then
pip install mysql-connector-python
@@ -211,7 +227,7 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: build images
run: |

2
.gitignore vendored
View File

@@ -10,6 +10,7 @@ docs/build
docs/source/_static/rest-api
docs/source/rbac/scope-table.md
.ipynb_checkpoints
jsx/build/
# ignore config file at the top-level of the repo
# but not sub-dirs
/jupyterhub_config.py
@@ -19,6 +20,7 @@ package-lock.json
share/jupyterhub/static/components
share/jupyterhub/static/css/style.min.css
share/jupyterhub/static/css/style.min.css.map
share/jupyterhub/static/js/admin-react.js*
*.egg-info
MANIFEST
.coverage

View File

@@ -11,34 +11,33 @@
repos:
# Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade
rev: v2.31.0
rev: v2.37.3
hooks:
- id: pyupgrade
args:
- --py36-plus
# Autoformat: Python code
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.7.1
- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: reorder-python-imports
- id: isort
# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 22.1.0
rev: 22.6.0
hooks:
- id: black
args: [--target-version=py36]
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.5.1
rev: v2.7.1
hooks:
- id: prettier
# Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
rev: v4.3.0
hooks:
- id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js
@@ -48,6 +47,6 @@ repos:
# Linting: Python code (see the file .flake8)
- repo: https://github.com/PyCQA/flake8
rev: "4.0.1"
rev: "5.0.4"
hooks:
- id: flake8

View File

@@ -6,134 +6,9 @@ you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md)
for a friendly and welcoming collaborative environment.
## Setting up a development environment
Please see our documentation on
<!--
https://jupyterhub.readthedocs.io/en/stable/contributing/setup.html
contains a lot of the same information. Should we merge the docs and
just have this page link to that one?
-->
- [Setting up a development install](https://jupyterhub.readthedocs.io/en/latest/contributing/setup.html)
- [Testing JupyterHub and linting code](https://jupyterhub.readthedocs.io/en/latest/contributing/tests.html)
JupyterHub requires Python >= 3.5 and nodejs.
As a Python project, a development install of JupyterHub follows standard practices for the basics (steps 1-2).
1. clone the repo
```bash
git clone https://github.com/jupyterhub/jupyterhub
```
2. do a development install with pip
```bash
cd jupyterhub
python3 -m pip install --editable .
```
3. install the development requirements,
which include things like testing tools
```bash
python3 -m pip install -r dev-requirements.txt
```
4. install configurable-http-proxy with npm:
```bash
npm install -g configurable-http-proxy
```
5. set up pre-commit hooks for automatic code formatting, etc.
```bash
pre-commit install
```
You can also invoke the pre-commit hook manually at any time with
```bash
pre-commit run
```
## Contributing
JupyterHub has adopted automatic code formatting so you shouldn't
need to worry too much about your code style.
As long as your code is valid,
the pre-commit hook should take care of how it should look.
You can invoke the pre-commit hook by hand at any time with:
```bash
pre-commit run
```
which should run any autoformatting on your code
and tell you about any errors it couldn't fix automatically.
You may also install [black integration](https://github.com/psf/black#editor-integration)
into your text editor to format code automatically.
If you have already committed files before setting up the pre-commit
hook with `pre-commit install`, you can fix everything up using
`pre-commit run --all-files`. You need to make the fixing commit
yourself after that.
## Testing
It's a good idea to write tests to exercise any new features,
or that trigger any bugs that you have fixed to catch regressions.
You can run the tests with:
```bash
pytest -v
```
in the repo directory. If you want to just run certain tests,
check out the [pytest docs](https://pytest.readthedocs.io/en/latest/usage.html)
for how pytest can be called.
For instance, to test only spawner-related things in the REST API:
```bash
pytest -v -k spawn jupyterhub/tests/test_api.py
```
The tests live in `jupyterhub/tests` and are organized roughly into:
1. `test_api.py` tests the REST API
2. `test_pages.py` tests loading the HTML pages
and other collections of tests for different components.
When writing a new test, there should usually be a test of
similar functionality already written and related tests should
be added nearby.
The fixtures live in `jupyterhub/tests/conftest.py`. There are
fixtures that can be used for JupyterHub components, such as:
- `app`: an instance of JupyterHub with mocked parts
- `auth_state_enabled`: enables persisting auth_state (like authentication tokens)
- `db`: a sqlite in-memory DB session
- `io_loop`: a Tornado event loop
- `event_loop`: a new asyncio event loop
- `user`: creates a new temporary user
- `admin_user`: creates a new temporary admin user
- single user servers
- `cleanup_after`: allows cleanup of single user servers between tests
- mocked service
- `MockServiceSpawner`: a spawner that mocks services for testing with a short poll interval
- `mockservice`: mocked service with no external service url
- `mockservice_url`: mocked service with a url to test external services
And fixtures to add functionality or spawning behavior:
- `admin_access`: grants admin access
- `no_patience`: sets slow-spawning timeouts to zero
- `slow_spawn`: enables the SlowSpawner (a spawner that takes a few seconds to start)
- `never_spawn`: enables the NeverSpawner (a spawner that will never start)
- `bad_spawn`: enables the BadSpawner (a spawner that fails immediately)
- `slow_bad_spawn`: enables the SlowBadSpawner (a spawner that fails after a short delay)
To read more about fixtures check out the
[pytest docs](https://docs.pytest.org/en/latest/fixture.html)
for how to use the existing fixtures, and how to create new ones.
When in doubt, feel free to [ask](https://gitter.im/jupyterhub/jupyterhub).
If you need some help, feel free to ask on [Gitter](https://gitter.im/jupyterhub/jupyterhub) or [Discourse](https://discourse.jupyter.org/).

View File

@@ -21,7 +21,7 @@
# your jupyterhub_config.py will be added automatically
# from your docker directory.
ARG BASE_IMAGE=ubuntu:focal-20200729
ARG BASE_IMAGE=ubuntu:22.04
FROM $BASE_IMAGE AS builder
USER root
@@ -37,6 +37,7 @@ RUN apt-get update \
python3-pycurl \
nodejs \
npm \
yarn \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -8,6 +8,7 @@ include *requirements.txt
include Dockerfile
graft onbuild
graft jsx
graft jupyterhub
graft scripts
graft share
@@ -18,6 +19,10 @@ graft ci
graft docs
prune docs/node_modules
# Intermediate javascript files
prune jsx/node_modules
prune jsx/build
# prune some large unused files from components
prune share/jupyterhub/static/components/bootstrap/dist/css
exclude share/jupyterhub/static/components/bootstrap/dist/fonts/*.svg

View File

@@ -59,7 +59,7 @@ JupyterHub also provides a
[REST API][]
for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
[rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation

20
ci/check_installed_data.py Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
# Check that installed package contains everything we expect
import os
from jupyterhub._data import DATA_FILES_PATH
print("Checking jupyterhub._data")
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
for subpath in (
"templates/page.html",
"static/css/style.min.css",
"static/components/jquery/dist/jquery.js",
"static/js/admin-react.js",
):
path = os.path.join(DATA_FILES_PATH, subpath)
assert os.path.exists(path), path
print("OK")

28
ci/check_sdist.py Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python
# Check that sdist contains everything we expect
import sys
import tarfile
from tarfile import TarFile
expected_files = [
"docs/requirements.txt",
"jsx/package.json",
"package.json",
"README.md",
]
assert len(sys.argv) == 2, "Expected one file"
print(f"Checking {sys.argv[1]}")
tar = tarfile.open(name=sys.argv[1], mode="r:gz")
try:
# Remove leading jupyterhub-VERSION/
filelist = {f.partition('/')[2] for f in tar.getnames()}
finally:
tar.close()
for e in expected_files:
assert e in filelist, f"{e} not found"
print("OK")

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_100 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
done

View File

@@ -0,0 +1,3 @@
__version__ = "22.0.0.dev0"
raise ImportError("Don't actually have greenlet")

View File

@@ -0,0 +1,13 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "greenlet"
description = 'Mock greenlet to allow install on 3.11'
requires-python = ">=3.7"
dynamic = ["version"]
[tool.hatch.version]
path = "greenlet.py"

View File

@@ -9,9 +9,13 @@ cryptography
html5lib # needed for beautifulsoup
jupyterlab >=3
mock
# nbclassic provides the '/tree/' handler, which we use in tests
# it is a transitive dependency via jupyterlab,
# but depend on it directly
nbclassic
pre-commit
pytest>=3.3
pytest-asyncio
pytest-asyncio>=0.17
pytest-cov
requests-mock
tbump

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub
license:
name: BSD-3-Clause
version: 2.2.2
version: 3.0.0
servers:
- url: /hub/api
security:
@@ -139,6 +139,16 @@ paths:
If unspecified, use api_page_default_limit.
schema:
type: number
- name: include_stopped_servers
in: query
description: |
Include stopped servers in user model(s).
Added in JupyterHub 3.0.
Allows retrieval of information about stopped servers,
such as activity and state fields.
schema:
type: boolean
allowEmptyValue: true
responses:
200:
description: The Hub's user list
@@ -560,7 +570,19 @@ paths:
description: A note attached to the token for future bookkeeping
roles:
type: array
description: A list of role names that the token should have
description: |
A list of role names from which to derive scopes.
This is a shortcut for assigning collections of scopes;
Tokens do not retain role assignment.
(Changed in 3.0: roles are immediately resolved to scopes
instead of stored on roles.)
items:
type: string
scopes:
type: array
description: |
A list of scopes that the token should have.
(new in JupyterHub 3.0).
items:
type: string
required: false
@@ -1148,7 +1170,11 @@ components:
format: date-time
servers:
type: array
description: The active servers for this user.
description: |
The servers for this user.
By default: only includes _active_ servers.
Changed in 3.0: if `?include_stopped_servers` parameter is specified,
stopped servers will be included as well.
items:
$ref: "#/components/schemas/Server"
auth_state:
@@ -1170,6 +1196,15 @@ components:
description: |
Whether the server is ready for traffic.
Will always be false when any transition is pending.
stopped:
type: boolean
description: |
Whether the server is stopped. Added in JupyterHub 3.0,
and only useful when using the `?include_stopped_servers`
request parameter.
Now that stopped servers may be included (since JupyterHub 3.0),
this is the simplest way to select stopped servers.
Always equivalent to `not (ready or pending)`.
pending:
type: string
description: |
@@ -1314,7 +1349,16 @@ components:
description: The service that owns the token (undefined of owned by a user)
roles:
type: array
description: The names of roles this token has
description:
Deprecated in JupyterHub 3, always an empty list. Tokens have
'scopes' starting from JupyterHub 3.
items:
type: string
scopes:
type: array
description:
List of scopes this token has been assigned. New in JupyterHub
3. In JupyterHub 2.x, tokens were assigned 'roles' insead of scopes.
items:
type: string
note:
@@ -1370,6 +1414,9 @@ components:
inherit:
Everything that the token-owning entity can access _(metascope
for tokens)_
admin-ui:
Access the admin page. Permission to take actions via the admin
page granted separately.
admin:users:
Read, write, create and delete users and their authentication
state, not including their servers or tokens.

View File

@@ -1,4 +1,4 @@
# Common log messages emitted by JupyterHub
# Interpreting common log messages
When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common
@@ -35,3 +35,38 @@ URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-
### Actions you can take
This log message is benign, and there is usually no action for you to take.
## JupyterHub Singleuser Version mismatch
### Example
```
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
```
### Cause
JupyterHub requires the `jupyterhub` python package installed inside the image or
environment the user server starts in. This message indicates that the version of
the `jupyterhub` package installed inside the user image or environment is not
the same version as the JupyterHub server itself. This is not necessarily always a
problem - some version drift is mostly acceptable, and the only two known cases of
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
changelog before upgrading!**. The primary problems this _could_ cause are:
1. Infinite redirect loops after the user server starts
2. Missing expected environment variables in the user server once it starts
3. Failure for the started user server to authenticate with the JupyterHub server -
note that this is _not_ the same as _user authentication_ failing!
However, for the most part, unless you are seeing these specific issues, the log
message should be counted as a warning to get the `jupyterhub` package versions
aligned, rather than as an indicator of an existing problem.
### Actions you can take
Upgrade the version of the `jupyterhub` package in your user environment or image
so it matches the version of JupyterHub running your JupyterHub server! If you
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)

File diff suppressed because one or more lines are too long

View File

@@ -48,7 +48,7 @@ version = '%i.%i' % jupyterhub.version_info[:2]
# The full version, including alpha/beta/rc tags.
release = jupyterhub.__version__
language = None
language = "en"
exclude_patterns = []
pygments_style = 'sphinx'
todo_include_todos = False
@@ -56,13 +56,15 @@ todo_include_todos = False
# Set the default role so we can use `foo` instead of ``foo``
default_role = 'literal'
# -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub
from docutils import nodes
from sphinx.directives.other import SphinxDirective
from contextlib import redirect_stdout
from io import StringIO
from docutils import nodes
from sphinx.directives.other import SphinxDirective
# -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub
# create a temp instance of JupyterHub just to get the output of the generate-config
# and help --all commands.
jupyterhub_app = JupyterHub()

View File

@@ -16,7 +16,7 @@ Install Python
--------------
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
requires you have at least version 3.5 installed locally. If you havent
requires you have at least version 3.6 installed locally. If you havent
installed Python before, the recommended way to install it is to use
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the Python 3 version,
and **not** the Python 2 version!
@@ -24,11 +24,10 @@ and **not** the Python 2 version!
Install nodejs
--------------
``configurable-http-proxy``, the default proxy implementation for
JupyterHub, is written in Javascript to run on `NodeJS
<https://nodejs.org/en/>`_. If you have not installed nodejs before, we
recommend installing it in the ``miniconda`` environment you set up for
Python. You can do so with ``conda install nodejs``.
`NodeJS 12+ <https://nodejs.org/en/>`_ is required for building some JavaScript components.
``configurable-http-proxy``, the default proxy implementation for JupyterHub, is written in Javascript.
If you have not installed nodejs before, we recommend installing it in the ``miniconda`` environment you set up for Python.
You can do so with ``conda install nodejs``.
Install git
-----------
@@ -46,7 +45,7 @@ their effects quickly. You need to do a developer install to make that
happen.
.. note:: This guide does not attempt to dictate *how* development
environements should be isolated since that is a personal preference and can
environments should be isolated since that is a personal preference and can
be achieved in many ways, for example `tox`, `conda`, `docker`, etc. See this
`forum thread <https://discourse.jupyter.org/t/thoughts-on-using-tox/3497>`_ for
a more detailed discussion.
@@ -66,7 +65,7 @@ happen.
python -V
This should return a version number greater than or equal to 3.5.
This should return a version number greater than or equal to 3.6.
.. code:: bash
@@ -74,12 +73,11 @@ happen.
This should return a version number greater than or equal to 5.0.
3. Install ``configurable-http-proxy``. This is required to run
JupyterHub.
3. Install ``configurable-http-proxy`` (required to run and test the default JupyterHub configuration) and ``yarn`` (required to build some components):
.. code:: bash
npm install -g configurable-http-proxy
npm install -g configurable-http-proxy yarn
If you get an error that says ``Error: EACCES: permission denied``,
you might need to prefix the command with ``sudo``. If you do not
@@ -87,11 +85,17 @@ happen.
.. code:: bash
npm install configurable-http-proxy
npm install configurable-http-proxy yarn
export PATH=$PATH:$(pwd)/node_modules/.bin
The second line needs to be run every time you open a new terminal.
If you are using conda you can instead run:
.. code:: bash
conda install configurable-http-proxy yarn
4. Install the python packages required for JupyterHub development.
.. code:: bash
@@ -186,3 +190,4 @@ development updates, with:
python3 setup.py js # fetch updated client-side js
python3 setup.py css # recompile CSS from LESS sources
python3 setup.py jsx # build React admin app

View File

@@ -1,8 +1,8 @@
.. _contributing/tests:
==================
Testing JupyterHub
==================
===================================
Testing JupyterHub and linting code
===================================
Unit test help validate that JupyterHub works the way we think it does,
and continues to do so when changes occur. They also help communicate
@@ -57,6 +57,50 @@ Running the tests
pytest -v jupyterhub/tests/test_api.py::test_shutdown
See the `pytest usage documentation <https://pytest.readthedocs.io/en/latest/usage.html>`_ for more details.
Test organisation
=================
The tests live in ``jupyterhub/tests`` and are organized roughly into:
#. ``test_api.py`` tests the REST API
#. ``test_pages.py`` tests loading the HTML pages
and other collections of tests for different components.
When writing a new test, there should usually be a test of
similar functionality already written and related tests should
be added nearby.
The fixtures live in ``jupyterhub/tests/conftest.py``. There are
fixtures that can be used for JupyterHub components, such as:
- ``app``: an instance of JupyterHub with mocked parts
- ``auth_state_enabled``: enables persisting auth_state (like authentication tokens)
- ``db``: a sqlite in-memory DB session
- ``io_loop```: a Tornado event loop
- ``event_loop``: a new asyncio event loop
- ``user``: creates a new temporary user
- ``admin_user``: creates a new temporary admin user
- single user servers
- ``cleanup_after``: allows cleanup of single user servers between tests
- mocked service
- ``MockServiceSpawner``: a spawner that mocks services for testing with a short poll interval
- ``mockservice```: mocked service with no external service url
- ``mockservice_url``: mocked service with a url to test external services
And fixtures to add functionality or spawning behavior:
- ``admin_access``: grants admin access
- ``no_patience```: sets slow-spawning timeouts to zero
- ``slow_spawn``: enables the SlowSpawner (a spawner that takes a few seconds to start)
- ``never_spawn``: enables the NeverSpawner (a spawner that will never start)
- ``bad_spawn``: enables the BadSpawner (a spawner that fails immediately)
- ``slow_bad_spawn``: enables the SlowBadSpawner (a spawner that fails after a short delay)
See the `pytest fixtures documentation <https://pytest.readthedocs.io/en/latest/fixture.html>`_
for how to use the existing fixtures, and how to create new ones.
Troubleshooting Test Failures
=============================
@@ -66,3 +110,27 @@ All the tests are failing
Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and
can launch ``jupyterhub`` from the terminal.
Code formatting and linting
===========================
JupyterHub has adopted automatic code formatting and linting.
As long as your code is valid, the pre-commit hook should take care of how it should look.
You can invoke the pre-commit hook by hand at any time with:
.. code:: bash
pre-commit run
which should run any autoformatting on your code and tell you about any errors it couldn't fix automatically.
You may also install `black integration <https://github.com/psf/black#editor-integration>`_
into your text editor to format code automatically.
If you have already committed files before running pre-commit you can fix everything using:
.. code:: bash
pre-commit run --all-files
And committing the changes.

View File

@@ -183,12 +183,6 @@ itself, ``jupyterhub_config.py``, as a binary string:
c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING')
.. important::
If the cookie secret value changes for the Hub, all single-user notebook
servers must also be restarted.
.. _cookies:
Cookies used by JupyterHub authentication

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -1,3 +1,5 @@
(RBAC)=
# JupyterHub RBAC
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.

View File

@@ -7,7 +7,7 @@ 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.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` 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.**
@@ -27,7 +27,6 @@ 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.
@@ -41,7 +40,7 @@ Services do not have a default role. Services without roles have no access to th
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.
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 scopes are requested for a new token, the token is assigned the scopes of the `token` role.
(define-role-target)=

View File

@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
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`.
2. default token scope `inherit`.
(default-user-scope-target)=
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
### 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}`.
The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` 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 has default `user` role, the `inherit` 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.
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
(horizontal-filtering-target)=
@@ -72,13 +72,31 @@ Requested resources are filtered based on the filter of the corresponding scope.
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
(self-referencing-filters)=
### Self-referencing filters
There are some 'shortcut' filters,
which can be applied to all scopes,
that filter based on the entities associated with the request.
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.
:::{versionadded} 3.0
`!service` and `!server` filters.
:::
In addition to `!user`, _tokens_ may have filters `!service`
or `!server`, which expand similarly to `!service=servicename`
and `!server=servername`.
This only applies to tokens issued via the OAuth flow.
In these cases, the name is the _issuing_ entity (a service or single-user server),
so that access can be restricted to the issuing service,
e.g. `access:servers!server` would grant access only to the server that requested the token.
These filters can be applied to any scope.
(vertical-filtering-target)=
@@ -114,11 +132,170 @@ There are four exceptions to the general {ref}`scope conventions <scope-conventi
```
:::{versionadded} 3.0
The `admin-ui` scope is added to explicitly grant access to the admin page,
rather than combining `admin:users` and `admin:servers` permissions.
This means a deployment can enable the admin page with only a subset of functionality enabled.
Note that this means actions to take _via_ the admin UI
and access _to_ the admin UI are separated.
For example, it generally doesn't make sense to grant
`admin-ui` without at least `list:users` for at least some subset of users.
For example:
```python
c.JupyterHub.load_roles = [
{
"name": "instructor-data8",
"scopes": [
# access to the admin page
"admin-ui",
# list users in the class group
"list:users!group=students-data8",
# start/stop servers for users in the class
"admin:servers!group=students-data8",
# access servers for users in the class
"access:servers!group=students-data8",
],
"group": ["instructors-data8"],
}
]
```
will grant instructors in the data8 course permission to:
1. view the admin UI
2. see students in the class (but not all users)
3. start/stop/access servers for users in the class
4. but _not_ permission to administer the users themselves (e.g. change their permissions, etc.)
:::
```{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.
```
(custom-scopes)=
### Custom scopes
:::{versionadded} 3.0
:::
JupyterHub 3.0 introduces support for custom scopes.
Services that use JupyterHub for authentication and want to implement their own granular access may define additional _custom_ scopes and assign them to users with JupyterHub roles.
% Note: keep in sync with pattern/description in jupyterhub/scopes.py
Custom scope names must start with `custom:`
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (`-_:*`).
The part after `custom:` must start with a letter or number.
Scopes may not end with a hyphen or colon.
The only strict requirement is that a custom scope definition must have a `description`.
It _may_ also have `subscopes` if you are defining multiple scopes that have a natural hierarchy,
For example:
```python
c.JupyterHub.custom_scopes = {
"custom:myservice:read": {
"description": "read-only access to myservice",
},
"custom:myservice:write": {
"description": "write access to myservice",
# write permission implies read permission
"subscopes": [
"custom:myservice:read",
],
},
}
c.JupyterHub.load_roles = [
# graders have read-only access to the service
{
"name": "service-user",
"groups": ["graders"],
"scopes": [
"custom:myservice:read",
"access:service!service=myservice",
],
},
# instructors have read and write access to the service
{
"name": "service-admin",
"groups": ["instructors"],
"scopes": [
"custom:myservice:write",
"access:service!service=myservice",
],
},
]
```
In the above configuration, two scopes are defined:
- `custom:myservice:read` grants read-only access to the service, and
- `custom:myservice:write` grants write access to the service
- write access _implies_ read access via the `subscope`
These custom scopes are assigned to two groups via `roles`:
- users in the group `graders` are granted read access to the service
- users in the group `instructors` are
- both are granted _access_ to the service via `access:service!service=myservice`
When the service completes OAuth, it will retrieve the user model from `/hub/api/user`.
This model includes a `scopes` field which is a list of authorized scope for the request,
which can be used.
```python
def require_scope(scope):
"""decorator to require a scope to perform an action"""
def wrapper(func):
@functools.wraps(func)
def wrapped_func(request):
user = fetch_hub_api_user(request.token)
if scope not in user["scopes"]:
raise HTTP403(f"Requires scope {scope}")
else:
return func()
return wrapper
@require_scope("custom:myservice:read")
async def read_something(request):
...
@require_scope("custom:myservice:write")
async def write_something(request):
...
```
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
against the `.hub_scopes` attribute of each Handler
(the default is populated from `$JUPYTERHUB_OAUTH_ACCESS_SCOPES` and usually `access:services!service=myservice`).
:::{versionchanged} 3.0
The JUPYTERHUB_OAUTH_SCOPES environment variable is deprecated and renamed to JUPYTERHUB_OAUTH_ACCESS_SCOPES,
to avoid ambiguity with JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES
:::
```python
from tornado import web
from jupyterhub.services.auth import HubOAuthenticated
class MyHandler(HubOAuthenticated, BaseHandler):
hub_scopes = ["custom:myservice:read"]
@web.authenticated
def get(self):
...
```
Existing scope filters (`!user=`, etc.) may be applied to custom scopes.
Custom scope _filters_ are NOT supported.
### Scopes and APIs
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).

View File

@@ -7,11 +7,11 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
```{admonition} **Scope variable nomenclature**
:class: tip
- _scopes_ \
List of scopes with abbreviations (used in role definitions). E.g., `["users:activity!user"]`.
List of scopes that may contain abbreviations (used in role definitions). E.g., `["users:activity!user", "self"]`.
- _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"}`.
Set of fully 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"]}}`.
Dictionary represenation 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_ \
@@ -22,27 +22,47 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
## 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 roles** refers to determining which roles a user, service, 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.
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific scopes or making an API request. The following sections provide more details.
(requesting-api-token-target)=
### Requesting API token with specific roles
### Requesting API token with specific scopes
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)).
:::{versionchanged} 3.0
API tokens have _scopes_ instead of roles,
so that their permissions cannot be updated.
RBAC adds several steps into the token issue flow.
You may still request roles for a token,
but those roles will be evaluated to the corresponding _scopes_ immediately.
If no roles are requested, the token is issued with the default `token` role (providing the requester is allowed to create the token).
Prior to 3.0, tokens stored _roles_,
which meant their scopes were resolved on each request.
:::
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.
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific permissions.
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>`).
RBAC is involved in several stages of the OAuth token flow.
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.
When requesting a token via the tokens API (`/users/:name/tokens`), or the token page (`/hub/token`),
if no scopes are requested, the token is issued with the permissions stored on the default `token` role
(providing the requester is allowed to create the token).
OAuth tokens are also requested via OAuth flow
If the token is requested with any scopes, 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 permissions of the token or token owner,
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 scope _intersection_;
the Hub logs a warning in this case (see {ref}`Figure 2 <api-request-chart>`).
Resolving a token's scope (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 own scopes 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.
If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested scopes; 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.
@@ -55,9 +75,9 @@ 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.
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).
When an API request is performed, the requesting API token's scopes are again intersected with its owner's (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:

View File

@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
### 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.
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 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

@@ -231,8 +231,8 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
httpd.conf amendments:
```bash
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE,P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [P,L]
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/

View File

@@ -35,6 +35,17 @@ A Service may have the following properties:
the service will be added to the proxy at `/services/:name`
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
an API token to perform API requests to the Hub
- `display: bool (default - True)` - When set to true, display a link to the
service's URL under the 'Services' dropdown in user's hub home page.
- `oauth_no_confirm: bool (default - False)` - When set to true,
skip the OAuth confirmation page when users access this service.
By default, when users authenticate with a service using JupyterHub,
they are prompted to confirm that they want to grant that service
access to their credentials.
Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
and shouldn't need extra prompts for login.
If a service is also to be managed by the Hub, it has a few extra options:
@@ -114,7 +125,10 @@ 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.
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service
(deprecated in 3.0, use JUPYTERHUB_OAUTH_ACCESS_SCOPES).
JUPYTERHUB_OAUTH_ACCESS_SCOPES: JSON-serialized list of scopes to use for allowing access to the service (new in 3.0).
JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES: JSON-serialized list of scopes that can be requested by the oauth client on behalf of users (new in 3.0).
```
For the previous 'cull idle' Service example, these environment variables
@@ -374,7 +388,7 @@ 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`.
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_ACCESS_SCOPES`.
An example of using an Externally-Managed Service and authentication is
in [nbviewer README][nbviewer example] section on securing the notebook viewer,

View File

@@ -308,6 +308,9 @@ The process environment is returned by `Spawner.get_env`, which specifies the fo
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`
- JUPYTERHUB_OAUTH_ACCESS_SCOPES - the scopes required to access the server (called JUPYTERHUB_OAUTH_SCOPES prior to 3.0)
- JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES - the scopes the service is allowed to request.
If no scopes are requested explicitly, these scopes will be requested.
Optional environment variables, depending on configuration:

View File

@@ -371,7 +371,7 @@ a JupyterHub deployment. The commands are:
- System and deployment information
```bash
jupyter troubleshooting
jupyter troubleshoot
```
- Kernel information

View File

@@ -7,9 +7,10 @@ to enable testing without administrative privileges.
c = get_config() # noqa
c.Application.log_level = 'DEBUG'
from oauthenticator.azuread import AzureAdOAuthenticator
import os
from oauthenticator.azuread import AzureAdOAuthenticator
c.JupyterHub.authenticator_class = AzureAdOAuthenticator
c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID")

View File

@@ -0,0 +1,132 @@
import os
from functools import wraps
from html import escape
from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.web import Application, RequestHandler, authenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
from jupyterhub.utils import url_path_join
SCOPE_PREFIX = "custom:grades"
READ_SCOPE = f"{SCOPE_PREFIX}:read"
WRITE_SCOPE = f"{SCOPE_PREFIX}:write"
def require_scope(scopes):
"""Decorator to require scopes
For use if multiple methods on one Handler
may want different scopes,
so class-level .hub_scopes is insufficient
(e.g. read for GET, write for POST).
"""
if isinstance(scopes, str):
scopes = [scopes]
def wrap(method):
"""The actual decorator"""
@wraps(method)
@authenticated
def wrapped(self, *args, **kwargs):
self.hub_scopes = scopes
return method(self, *args, **kwargs)
return wrapped
return wrap
class MyGradesHandler(HubOAuthenticated, RequestHandler):
# no hub_scopes, anyone with access to this service
# will be able to visit this URL
@authenticated
def get(self):
self.write("<h1>My grade</h1>")
name = self.current_user["name"]
grades = self.settings["grades"]
self.write(f"<p>My name is: {escape(name)}</p>")
if name in grades:
self.write(f"<p>My grade is: {escape(str(grades[name]))}</p>")
else:
self.write("<p>No grade entered</p>")
if READ_SCOPE in self.current_user["scopes"]:
self.write('<a href="grades/">enter grades</a>')
class GradesHandler(HubOAuthenticated, RequestHandler):
# default scope for this Handler: read-only
hub_scopes = [READ_SCOPE]
def _render(self):
grades = self.settings["grades"]
self.write("<h1>All grades</h1>")
self.write("<table>")
self.write("<tr><th>Student</th><th>Grade</th></tr>")
for student, grade in grades.items():
qstudent = escape(student)
qgrade = escape(str(grade))
self.write(
f"""
<tr>
<td class="student">{qstudent}</td>
<td class="grade">{qgrade}</td>
</tr>
"""
)
if WRITE_SCOPE in self.current_user["scopes"]:
self.write("Enter grade:")
self.write(
"""
<form action=. method=POST>
<input name=student placeholder=student></input>
<input kind=number name=grade placeholder=grade></input>
<input type="submit" value="Submit">
"""
)
@require_scope([READ_SCOPE])
async def get(self):
self._render()
# POST requires WRITE_SCOPE instead of READ_SCOPE
@require_scope([WRITE_SCOPE])
async def post(self):
name = self.get_argument("student")
grade = self.get_argument("grade")
self.settings["grades"][name] = grade
self._render()
def main():
base_url = os.environ['JUPYTERHUB_SERVICE_PREFIX']
app = Application(
[
(base_url, MyGradesHandler),
(url_path_join(base_url, 'grades/'), GradesHandler),
(
url_path_join(base_url, 'oauth_callback'),
HubOAuthCallbackHandler,
),
],
cookie_secret=os.urandom(32),
grades={"student": 53},
)
http_server = HTTPServer(app)
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
http_server.listen(url.port, url.hostname)
try:
IOLoop.current().start()
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,52 @@
import sys
c = get_config() # noqa
c.JupyterHub.services = [
{
'name': 'grades',
'url': 'http://127.0.0.1:10101',
'command': [sys.executable, './grades.py'],
'oauth_client_allowed_scopes': [
'custom:grades:write',
'custom:grades:read',
],
},
]
c.JupyterHub.custom_scopes = {
"custom:grades:read": {
"description": "read-access to all grades",
},
"custom:grades:write": {
"description": "Enter new grades",
"subscopes": ["custom:grades:read"],
},
}
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to services
"scopes": ["access:services", "self"],
},
{
"name": "grader",
# grant graders access to write grades
"scopes": ["custom:grades:write"],
"users": ["grader"],
},
{
"name": "instructor",
# grant instructors access to read, but not write grades
"scopes": ["custom:grades:read"],
"users": ["instructor"],
},
]
c.JupyterHub.allowed_users = {"instructor", "grader", "student"}
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
c.JupyterHub.log_level = 10

View File

@@ -5,13 +5,10 @@ so all URLs and requests necessary for OAuth with JupyterHub should be in one pl
"""
import json
import os
from urllib.parse import urlencode
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from tornado import log
from tornado import web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPRequest
from tornado import log, web
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import url_concat
from tornado.ioloop import IOLoop

View File

@@ -2,6 +2,8 @@
# 1. start/stop servers, and
# 2. access the server API
c = get_config() # noqa
c.JupyterHub.load_roles = [
{
"name": "launcher",

View File

@@ -16,7 +16,6 @@ import time
import requests
log = logging.getLogger(__name__)

View File

@@ -3,9 +3,7 @@ import datetime
import json
import os
from tornado import escape
from tornado import ioloop
from tornado import web
from tornado import escape, ioloop, web
from jupyterhub.services.auth import HubAuthenticated

View File

@@ -1,8 +1,5 @@
from datetime import datetime
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Any, Dict, List, Optional
from pydantic import BaseModel

View File

@@ -1,9 +1,7 @@
import json
import os
from fastapi import HTTPException
from fastapi import Security
from fastapi import status
from fastapi import HTTPException, Security, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from fastapi.security.api_key import APIKeyQuery

View File

@@ -1,14 +1,9 @@
import os
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Form
from fastapi import Request
from fastapi import APIRouter, Depends, Form, Request
from .client import get_client
from .models import AuthorizationError
from .models import HubApiError
from .models import User
from .models import AuthorizationError, HubApiError, User
from .security import get_current_user
# APIRouter prefix cannot end in /

View File

@@ -7,16 +7,10 @@ import os
import secrets
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 flask import session
from flask import Flask, Response, make_response, redirect, request, session
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)

View File

@@ -26,7 +26,7 @@ After logging in with any username and password, you should see a JSON dump of y
```
What is contained in the model will depend on the permissions
requested in the `oauth_roles` configuration of the service `whoami-oauth` service.
requested in the `oauth_client_allowed_scopes` configuration of the service `whoami-oauth` service.
The default is the minimum required for identification and access to the service,
which will provide the username and current scopes.

View File

@@ -14,11 +14,11 @@ c.JupyterHub.services = [
# only requesting access to the service,
# and identification by name,
# nothing more.
# Specifying 'oauth_roles' as a list of role names
# Specifying 'oauth_client_allowed_scopes' as a list of scopes
# allows requesting more information about users,
# or the ability to take actions on users' behalf, as required.
# The default 'token' role has the full permissions of its owner:
# 'oauth_roles': ['token'],
# the 'inherit' scope means the full permissions of the owner
# 'oauth_client_allowed_scopes': ['inherit'],
},
]

View File

@@ -10,12 +10,9 @@ from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.web import Application
from tornado.web import authenticated
from tornado.web import RequestHandler
from tornado.web import Application, RequestHandler, authenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler
from jupyterhub.services.auth import HubOAuthenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
from jupyterhub.utils import url_path_join

View File

@@ -10,9 +10,7 @@ from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.web import Application
from tornado.web import authenticated
from tornado.web import RequestHandler
from tornado.web import Application, RequestHandler, authenticated
from jupyterhub.services.auth import HubAuthenticated

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

@@ -8,7 +8,7 @@
"scripts": {
"build": "yarn && webpack",
"hot": "webpack && webpack-dev-server",
"place": "cp -r build/admin-react.js ../share/jupyterhub/static/js/admin-react.js",
"place": "cp build/admin-react.js* ../share/jupyterhub/static/js/",
"test": "jest --verbose",
"snap": "jest --updateSnapshot",
"lint": "eslint --ext .jsx --ext .js src/",
@@ -28,43 +28,48 @@
}
},
"dependencies": {
"bootstrap": "^4.5.3",
"history": "^5.0.0",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-bootstrap": "^2.1.1",
"react-dom": "^17.0.1",
"react-icons": "^4.1.0",
"react-multi-select-component": "^3.0.7",
"react-object-table-viewer": "^1.0.7",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"recompose": "npm:react-recompose@^0.31.2",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.9"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"@testing-library/jest-dom": "^5.15.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"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": {
"@webpack-cli/serve": "^1.7.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"babel-jest": "^26.6.3",
"babel-loader": "^8.2.1",
"css-loader": "^5.0.1",
"enzyme": "^3.11.0",
"eslint": "^7.18.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-unused-imports": "^1.1.1",
"file-loader": "^6.2.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"prettier": "^2.2.1"
"prettier": "^2.2.1",
"sinon": "^13.0.1",
"style-loader": "^2.0.0",
"webpack": "^5.6.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3"
}
}

View File

@@ -1,10 +1,9 @@
import React, { useEffect } from "react";
import React 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";
@@ -20,23 +19,6 @@ 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}>

View File

@@ -1,21 +1,48 @@
export const initialState = {
user_data: undefined,
user_page: 0,
user_page: { offset: 0, limit: window.api_page_limit || 100 },
name_filter: "",
groups_data: undefined,
groups_page: 0,
limit: window.api_page_limit,
groups_page: { offset: 0, limit: window.api_page_limit || 100 },
limit: window.api_page_limit || 100,
};
export const reducers = (state = initialState, action) => {
switch (action.type) {
// Updates the client user model data and stores the page
case "USER_OFFSET":
return Object.assign({}, state, {
user_page: Object.assign({}, state.user_page, {
offset: action.value.offset,
}),
});
case "USER_NAME_FILTER":
// set offset to 0 if name filter changed,
// otherwise leave it alone
const newOffset =
action.value.name_filter !== state.name_filter ? 0 : state.name_filter;
return Object.assign({}, state, {
user_page: Object.assign({}, state.user_page, {
offset: newOffset,
}),
name_filter: action.value.name_filter,
});
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
// Updates the client group user model data and stores the page
case "GROUPS_OFFSET":
return Object.assign({}, state, {
groups_page: Object.assign({}, state.groups_page, {
offset: action.value.offset,
}),
});
case "GROUPS_PAGE":
return Object.assign({}, state, {
groups_page: action.value.page,

View File

@@ -60,7 +60,10 @@ const AddUser = (props) => {
placeholder="usernames separated by line"
data-testid="user-textarea"
onBlur={(e) => {
let split_users = e.target.value.split("\n");
let split_users = e.target.value
.split("\n")
.map((u) => u.trim())
.filter((u) => u.length > 0);
setUsers(split_users);
}}
></textarea>
@@ -88,17 +91,7 @@ const AddUser = (props) => {
data-testid="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)
addUsers(users, admin)
.then((data) =>
data.status < 300
? updateUsers(0, limit)

View File

@@ -70,12 +70,12 @@ test("Removes users when they fail Regex", async () => {
let textarea = screen.getByTestId("user-textarea");
let submit = screen.getByTestId("submit");
fireEvent.blur(textarea, { target: { value: "foo\nbar\n!!*&*" } });
fireEvent.blur(textarea, { target: { value: "foo \n bar\na@b.co\n \n\n" } });
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
});
test("Correctly submits admin", async () => {

View File

@@ -59,7 +59,7 @@ const CreateGroup = (props) => {
value={groupName}
placeholder="group name..."
onChange={(e) => {
setGroupName(e.target.value);
setGroupName(e.target.value.trim());
}}
></input>
</div>

View File

@@ -125,38 +125,12 @@ const EditUser = (props) => {
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(() =>
setErrorAlert(
`Could not update users list.`
)
)
: setErrorAlert(`Failed to edit user.`);
})
.catch(() => {
setErrorAlert(`Failed to edit user.`);
});
} else {
setErrorAlert(
`Failed to edit user. Make sure the username does not contain special characters.`
);
}
} else {
editUser(username, username, admin)
editUser(
username,
updatedUsername != "" ? updatedUsername : username,
admin
)
.then((data) => {
data.status < 300
? updateUsers(0, limit)

View File

@@ -122,7 +122,6 @@ const GroupEdit = (props) => {
: setErrorAlert(`Failed to edit group.`);
})
.catch(() => {
console.log("outer");
setErrorAlert(`Failed to edit group.`);
});
}}

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
@@ -6,23 +6,26 @@ 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),
var 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"));
dispatch = useDispatch();
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var offset = groups_page ? groups_page.offset : 0;
const setOffset = (offset) => {
dispatch({
type: "GROUPS_OFFSET",
value: {
offset: offset,
},
});
};
var limit = groups_page ? groups_page.limit : window.api_page_limit;
var total = groups_page ? groups_page.total : undefined;
var { updateGroups, history } = props;
if (!groups_data || !user_data) {
return <div data-testid="no-show"></div>;
}
const dispatchPageChange = (data, page) => {
const dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
@@ -32,10 +35,14 @@ const Groups = (props) => {
});
};
if (groups_page != page) {
updateGroups(...slice).then((data) => {
dispatchPageChange(data, page);
});
useEffect(() => {
updateGroups(offset, limit).then((data) =>
dispatchPageUpdate(data.items, data._pagination)
);
}, [offset, limit]);
if (!groups_data || !groups_page) {
return <div data-testid="no-show"></div>;
}
return (
@@ -59,7 +66,6 @@ const Groups = (props) => {
pathname: "/group-edit",
state: {
group_data: e,
user_data: user_data,
},
}}
>
@@ -74,11 +80,12 @@ const Groups = (props) => {
)}
</ul>
<PaginationFooter
endpoint="/groups"
page={page}
offset={offset}
limit={limit}
numOffset={slice[0]}
numElements={groups_data.length}
visible={groups_data.length}
total={total}
next={() => setOffset(offset + limit)}
prev={() => setOffset(offset >= limit ? offset - limit : 0)}
/>
</div>
<div className="panel-footer">
@@ -102,8 +109,6 @@ const Groups = (props) => {
};
Groups.propTypes = {
user_data: PropTypes.array,
groups_data: PropTypes.array,
updateUsers: PropTypes.func,
updateGroups: PropTypes.func,
history: PropTypes.shape({

View File

@@ -1,53 +1,72 @@
import React from "react";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
import { initialState, reducers } from "../../Store";
import Groups from "./Groups";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
useDispatch: jest.fn(),
}));
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<Provider store={createStore(mockReducers, mockAppState())}>
<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"]}]'
),
limit: 10,
var mockReducers = jest.fn((state, action) => {
if (action.type === "GROUPS_PAGE" && !action.value.data) {
// no-op from mock, don't update state
return state;
}
state = reducers(state, action);
// mocked useSelector seems to cause a problem
// this should get the right state back?
// not sure
// useSelector.mockImplementation((callback) => callback(state);
return state;
});
var mockAppState = () =>
Object.assign({}, initialState, {
groups_data: [
{ kind: "group", name: "testgroup", users: [] },
{ kind: "group", name: "testgroup2", users: ["foo", "bar"] },
],
groups_page: {
offset: 0,
limit: 2,
total: 4,
next: {
offset: 2,
limit: 2,
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
},
},
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
mockReducers.mockClear();
});
test("Renders", async () => {
@@ -88,3 +107,30 @@ test("Renders nothing if required data is not available", async () => {
let noShow = screen.getByTestId("no-show");
expect(noShow).toBeVisible();
});
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
expect(callbackSpy).toBeCalledWith(0, 2);
var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.groups_page.offset).toEqual(0);
expect(lastState.groups_page.limit).toEqual(2);
let next = screen.getByTestId("paginate-next");
fireEvent.click(next);
lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.groups_page.offset).toEqual(2);
expect(lastState.groups_page.limit).toEqual(2);
// FIXME: mocked useSelector, state seem to prevent updateGroups from being called
// making the test environment not representative
// expect(callbackSpy).toHaveBeenCalledWith(2, 2);
});

View File

@@ -1,33 +1,40 @@
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;
let { offset, limit, visible, total, next, prev } = props;
return (
<div className="pagination-footer">
<p>
Displaying {numOffset}-{numOffset + numElements}
Displaying {offset}-{offset + visible}
<br></br>
<br></br>
{page >= 1 ? (
{offset >= 1 ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page - 1}`}>
<span className="active-pagination">Previous</span>
</Link>
<span
className="active-pagination"
data-testid="paginate-prev"
onClick={prev}
>
Previous
</span>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Previous</span>
</button>
)}
{numElements >= limit ? (
{offset + visible < total ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page + 1}`}>
<span className="active-pagination">Next</span>
</Link>
<span
className="active-pagination"
data-testid="paginate-next"
onClick={next}
>
Next
</span>
</button>
) : (
<button className="btn btn-sm btn-light spaced">

View File

@@ -1,8 +1,19 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { debounce } from "lodash";
import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
import {
Button,
Col,
Row,
FormControl,
Card,
CardGroup,
Collapse,
} from "react-bootstrap";
import ReactObjectTableViewer from "react-object-table-viewer";
import { Link } from "react-router-dom";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
@@ -10,8 +21,8 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const AccessServerButton = ({ userName, serverName }) => (
<a href={`/user/${userName}/${serverName || ""}`}>
const AccessServerButton = ({ url }) => (
<a href={url || ""}>
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
Access Server
</button>
@@ -19,6 +30,7 @@ const AccessServerButton = ({ userName, serverName }) => (
);
const ServerDashboard = (props) => {
let base_url = window.base_url || "/";
// 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)),
@@ -38,14 +50,15 @@ const ServerDashboard = (props) => {
var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
var [collapseStates, setCollapseStates] = useState({});
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"));
name_filter = useSelector((state) => state.name_filter);
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var offset = user_page ? user_page.offset : 0;
var limit = user_page ? user_page.limit : window.api_page_limit;
var total = user_page ? user_page.total : undefined;
const dispatch = useDispatch();
@@ -59,7 +72,7 @@ const ServerDashboard = (props) => {
history,
} = props;
var dispatchPageUpdate = (data, page) => {
const dispatchPageUpdate = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
@@ -69,13 +82,39 @@ const ServerDashboard = (props) => {
});
};
if (!user_data) {
const setOffset = (newOffset) => {
dispatch({
type: "USER_OFFSET",
value: {
offset: newOffset,
},
});
};
const setNameFilter = (name_filter) => {
dispatch({
type: "USER_NAME_FILTER",
value: {
name_filter: name_filter,
},
});
};
useEffect(() => {
updateUsers(offset, limit, name_filter)
.then((data) => dispatchPageUpdate(data.items, data._pagination))
.catch((err) => setErrorAlert("Failed to update user list."));
}, [offset, limit, name_filter]);
if (!user_data || !user_page) {
return <div data-testid="no-show"></div>;
}
if (page != user_page) {
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
}
var slice = [offset, limit, name_filter];
const handleSearch = debounce(async (event) => {
setNameFilter(event.target.value);
}, 300);
if (sortMethod != null) {
user_data = sortMethod(user_data);
@@ -94,7 +133,11 @@ const ServerDashboard = (props) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(
data.items,
data._pagination,
name_filter
);
})
.catch(() => {
setIsDisabled(false);
@@ -130,7 +173,11 @@ const ServerDashboard = (props) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(
data.items,
data._pagination,
name_filter
);
})
.catch(() => {
setErrorAlert(`Failed to update users list.`);
@@ -175,6 +222,139 @@ const ServerDashboard = (props) => {
);
};
const ServerRowTable = ({ data }) => {
const sortedData = Object.keys(data)
.sort()
.reduce(function (result, key) {
let value = data[key];
switch (key) {
case "last_activity":
case "created":
case "started":
// format timestamps
value = value ? timeSince(value) : value;
break;
}
if (Array.isArray(value)) {
// cast arrays (e.g. roles, groups) to string
value = value.sort().join(", ");
}
result[key] = value;
return result;
}, {});
return (
<ReactObjectTableViewer
className="table-striped table-bordered"
style={{
padding: "3px 6px",
margin: "auto",
}}
keyStyle={{
padding: "4px",
}}
valueStyle={{
padding: "4px",
}}
data={sortedData}
/>
);
};
const serverRow = (user, server) => {
const { servers, ...userNoServers } = user;
const serverNameDash = server.name ? `-${server.name}` : "";
const userServerName = user.name + serverNameDash;
const open = collapseStates[userServerName] || false;
return [
<tr key={`${userServerName}-row`} className="user-row">
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="caret"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
<td data-testid="user-row-server">
<p className="text-secondary">{server.name}</p>
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton serverName={server.name} userName={user.name} />
<AccessServerButton url={server.url} />
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
style={{ marginRight: 20 }}
/>
<a
href={`${base_url}spawn/${user.name}${
server.name ? "/" + server.name : ""
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>,
<tr>
<td
colSpan={6}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={userNoServers} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>,
];
};
let servers = user_data.flatMap((user) => {
let userServers = Object.values({
"": user.server || {},
@@ -203,11 +383,24 @@ const ServerDashboard = (props) => {
) : (
<></>
)}
<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">
<Row>
<Col md={4}>
<FormControl
type="text"
name="user_search"
placeholder="Search users"
aria-label="user-search"
defaultValue={name_filter}
onChange={handleSearch}
/>
</Col>
<Col md="auto" style={{ float: "right", margin: 15 }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</Col>
</Row>
<table className="table table-bordered table-hover">
<thead className="admin-table-head">
<tr>
<th id="user-header">
@@ -286,7 +479,11 @@ const ServerDashboard = (props) => {
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(
data.items,
data._pagination,
name_filter
);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
@@ -322,7 +519,11 @@ const ServerDashboard = (props) => {
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(
data.items,
data._pagination,
name_filter
);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
@@ -346,74 +547,16 @@ const ServerDashboard = (props) => {
</Button>
</td>
</tr>
{servers.map(([user, server], i) => {
server.name = server.name || "";
return (
<tr key={i + "row"} className="user-row">
<td data-testid="user-row-name">{user.name}</td>
<td data-testid="user-row-admin">
{user.admin ? "admin" : ""}
</td>
<td data-testid="user-row-server">
{server.name ? (
<p class="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity
? timeSince(server.last_activity)
: "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton
serverName={server.name}
userName={user.name}
/>
<AccessServerButton
serverName={server.name}
userName={user.name}
/>
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
/>
<a
href={`/spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>
);
})}
{servers.flatMap(([user, server]) => serverRow(user, server))}
</tbody>
</table>
<PaginationFooter
endpoint="/"
page={page}
offset={offset}
limit={limit}
numOffset={slice[0]}
numElements={user_data.length}
visible={user_data.length}
total={total}
next={() => setOffset(offset + limit)}
prev={() => setOffset(offset - limit)}
/>
<br></br>
</div>

View File

@@ -1,6 +1,7 @@
import React from "react";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import userEvent from "@testing-library/user-event";
import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
@@ -9,6 +10,10 @@ import { createStore } from "redux";
import regeneratorRuntime from "regenerator-runtime";
import ServerDashboard from "./ServerDashboard";
import { initialState, reducers } from "../../Store";
import * as sinon from "sinon";
let clock;
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -16,7 +21,7 @@ jest.mock("react-redux", () => ({
}));
var serverDashboardJsx = (spy) => (
<Provider store={createStore(() => {}, {})}>
<Provider store={createStore(mockReducers, mockAppState())}>
<HashRouter>
<Switch>
<ServerDashboard
@@ -38,13 +43,71 @@ var mockAsync = (data) =>
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
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":{}}]'
),
var mockAppState = () =>
Object.assign({}, initialState, {
user_data: [
{
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: {},
},
],
user_page: {
offset: 0,
limit: 2,
total: 4,
next: {
offset: 2,
limit: 2,
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
},
},
});
var mockReducers = jest.fn((state, action) => {
if (action.type === "USER_PAGE" && !action.value.data) {
// no-op from mock, don't update state
return state;
}
state = reducers(state, action);
// mocked useSelector seems to cause a problem
// this should get the right state back?
// not sure
// useSelector.mockImplementation((callback) => callback(state);
return state;
});
beforeEach(() => {
clock = sinon.useFakeTimers();
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
@@ -52,6 +115,8 @@ beforeEach(() => {
afterEach(() => {
useSelector.mockClear();
mockReducers.mockClear();
clock.restore();
});
test("Renders", async () => {
@@ -71,8 +136,8 @@ test("Renders users from props.user_data into table", async () => {
render(serverDashboardJsx(callbackSpy));
});
let foo = screen.getByText("foo");
let bar = screen.getByText("bar");
let foo = screen.getByTestId("user-name-div-foo");
let bar = screen.getByTestId("user-name-div-bar");
expect(foo).toBeVisible();
expect(bar).toBeVisible();
@@ -92,6 +157,18 @@ test("Renders correctly the status of a single-user server", async () => {
expect(stop).toBeVisible();
});
test("Renders spawn page link", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let link = screen.getByText("Spawn Page").closest("a");
let url = new URL(link.href);
expect(url.pathname).toEqual("/spawn/bar");
});
test("Invokes the startServer event on button click", async () => {
let callbackSpy = mockAsync();
@@ -151,12 +228,12 @@ test("Sorts according to username", async () => {
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
expect(first.textContent).toContain("bar");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
expect(first.textContent).toContain("foo");
});
test("Sorts according to admin", async () => {
@@ -189,12 +266,12 @@ test("Sorts according to last activity", async () => {
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
expect(first.textContent).toContain("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
expect(first.textContent).toContain("bar");
});
test("Sorts according to server status (running/not running)", async () => {
@@ -208,12 +285,53 @@ test("Sorts according to server status (running/not running)", async () => {
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
expect(first.textContent).toContain("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
expect(first.textContent).toContain("bar");
});
test("Shows server details with button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let button = screen.getByTestId("foo-collapse-button");
let collapse = screen.getByTestId("foo-collapse");
let collapseBar = screen.getByTestId("bar-collapse");
// expect().toBeVisible does not work here with collapse.
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
});
test("Renders nothing if required data is not available", async () => {
@@ -435,3 +553,94 @@ test("Shows a UI error dialogue when stop user server returns an improper status
expect(errorDialog).toBeVisible();
});
test("Search for user calls updateUsers with name filter", async () => {
let spy = mockAsync();
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
return Promise.resolve({
items: [],
_pagination: {
offset: offset,
limit: limit,
total: offset + limit * 2,
next: {
offset: offset + limit,
limit: limit,
},
},
});
});
await act(async () => {
render(
<Provider store={createStore(mockReducers, mockAppState())}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={mockUpdateUsers}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let search = screen.getByLabelText("user-search");
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
userEvent.type(search, "a");
expect(search.value).toEqual("a");
clock.tick(400);
expect(mockReducers.mock.calls).toHaveLength(3);
var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.name_filter).toEqual("a");
// TODO: this should
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
userEvent.type(search, "b");
expect(search.value).toEqual("ab");
clock.tick(400);
expect(mockReducers.mock.calls).toHaveLength(4);
lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.name_filter).toEqual("ab");
expect(lastState.user_page.offset).toEqual(0);
});
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
expect(callbackSpy).toBeCalledWith(0, 2, "");
expect(mockReducers.mock.results).toHaveLength(2);
lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
console.log(lastState);
expect(lastState.user_page.offset).toEqual(0);
expect(lastState.user_page.limit).toEqual(2);
let next = screen.getByTestId("paginate-next");
fireEvent.click(next);
clock.tick(400);
expect(mockReducers.mock.results).toHaveLength(3);
var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.user_page.offset).toEqual(2);
expect(lastState.user_page.limit).toEqual(2);
// FIXME: should call updateUsers, does in reality.
// tests don't reflect reality due to mocked state/useSelector
// unclear how to fix this.
// expect(callbackSpy.mock.calls).toHaveLength(2);
// expect(callbackSpy).toHaveBeenCalledWith(2, 2, "");
});

View File

@@ -1,11 +1,12 @@
export const jhapiRequest = (endpoint, method, data) => {
let base_url = window.base_url,
let base_url = window.base_url || "/",
api_url = `${base_url}hub/api`;
return fetch(api_url + endpoint, {
method: method,
json: true,
headers: {
"Content-Type": "application/json",
Accept: "application/jupyterhub-pagination+json",
},
body: data ? JSON.stringify(data) : null,
});

View File

@@ -2,10 +2,13 @@ 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()
),
updateUsers: (offset, limit, name_filter) =>
jhapiRequest(
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
name_filter || ""
}`,
"GET"
).then((data) => data.json()),
updateGroups: (offset, limit) =>
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
(data) => data.json()

View File

@@ -1,6 +1,5 @@
const webpack = require("webpack");
const path = require("path");
const express = require("express");
module.exports = {
entry: path.resolve(__dirname, "src", "App.jsx"),
@@ -34,16 +33,19 @@ module.exports = {
},
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: {
contentBase: path.resolve(__dirname, "build"),
static: {
directory: path.resolve(__dirname, "build"),
},
port: 9000,
before: (app, server) => {
onBeforeSetupMiddleware: (devServer) => {
const app = devServer.app;
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) => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1 @@
from ._version import __version__
from ._version import version_info
from ._version import __version__, version_info

View File

@@ -4,7 +4,7 @@
def get_data_files():
"""Walk up until we find share/jupyterhub"""
import sys
from os.path import join, abspath, dirname, exists, split
from os.path import abspath, dirname, exists, join, split
path = abspath(dirname(__file__))
starting_points = [path]

154
jupyterhub/_memoize.py Normal file
View File

@@ -0,0 +1,154 @@
"""Utilities for memoization
Note: a memoized function should always return an _immutable_
result to avoid later modifications polluting cached results.
"""
from collections import OrderedDict
from functools import wraps
class DoNotCache:
"""Wrapper to return a result without caching it.
In a function decorated with `@lru_cache_key`:
return DoNotCache(result)
is equivalent to:
return result # but don't cache it!
"""
def __init__(self, result):
self.result = result
class LRUCache:
"""A simple Least-Recently-Used (LRU) cache with a max size"""
def __init__(self, maxsize=1024):
self._cache = OrderedDict()
self.maxsize = maxsize
def __contains__(self, key):
return key in self._cache
def get(self, key, default=None):
"""Get an item from the cache"""
if key in self._cache:
# cache hit, bump to front of the queue for LRU
result = self._cache[key]
self._cache.move_to_end(key)
return result
return default
def set(self, key, value):
"""Store an entry in the cache
Purges oldest entry if cache is full
"""
self._cache[key] = value
# cache is full, purge oldest entry
if len(self._cache) > self.maxsize:
self._cache.popitem(last=False)
__getitem__ = get
__setitem__ = set
def lru_cache_key(key_func, maxsize=1024):
"""Like functools.lru_cache, but takes a custom key function,
as seen in sorted(key=func).
Useful for non-hashable arguments which have a known hashable equivalent (e.g. sets, lists),
or mutable objects where only immutable fields might be used
(e.g. User, where only username affects output).
For safety: Cached results should always be immutable,
such as using `frozenset` instead of mutable `set`.
Example:
@lru_cache_key(lambda user: user.name)
def func_user(user):
# output only varies by name
Args:
key (callable):
Should have the same signature as the decorated function.
Returns a hashable key to use in the cache
maxsize (int):
The maximum size of the cache.
"""
def cache_func(func):
cache = LRUCache(maxsize=maxsize)
# the actual decorated function:
@wraps(func)
def cached(*args, **kwargs):
cache_key = key_func(*args, **kwargs)
if cache_key in cache:
# cache hit
return cache[cache_key]
else:
# cache miss, call function and cache result
result = func(*args, **kwargs)
if isinstance(result, DoNotCache):
# DoNotCache prevents caching
result = result.result
else:
cache[cache_key] = result
return result
return cached
return cache_func
class FrozenDict(dict):
"""A frozen dictionary subclass
Immutable and hashable, so it can be used as a cache key
Values will be frozen with `.freeze(value)`
and must be hashable after freezing.
Not rigorous, but enough for our purposes.
"""
_hash = None
def __init__(self, d):
dict_set = dict.__setitem__
for key, value in d.items():
dict.__setitem__(self, key, self._freeze(value))
def _freeze(self, item):
"""Make values of a dict hashable
- list, set -> frozenset
- dict -> recursive _FrozenDict
- anything else: assumed hashable
"""
if isinstance(item, FrozenDict):
return item
elif isinstance(item, list):
return tuple(self._freeze(e) for e in item)
elif isinstance(item, set):
return frozenset(item)
elif isinstance(item, dict):
return FrozenDict(item)
else:
# any other type is assumed hashable
return item
def __setitem__(self, key):
raise RuntimeError("Cannot modify frozen {type(self).__name__}")
def update(self, other):
raise RuntimeError("Cannot modify frozen {type(self).__name__}")
def __hash__(self):
"""Cache hash because we are immutable"""
if self._hash is None:
self._hash = hash(tuple((key, value) for key, value in self.items()))
return self._hash

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (2, 2, 2, "", "")
version_info = (3, 0, 0, "", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -3,17 +3,16 @@ import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if 'jupyterhub' in sys.modules:
from traitlets.config import MultipleInstanceError
from jupyterhub.app import JupyterHub
app = None
@@ -42,6 +41,16 @@ target_metadata = orm.Base.metadata
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
# pass these to context.configure(**config_opts)
common_config_opts = dict(
# target_metadata for autogenerate
target_metadata=target_metadata,
# transaction per migration to ensure
# each migration is 'complete' before running the next one
# (e.g. dropped tables)
transaction_per_migration=True,
)
def run_migrations_offline():
"""Run migrations in 'offline' mode.
@@ -56,14 +65,15 @@ def run_migrations_offline():
"""
connectable = config.attributes.get('connection', None)
config_opts = {}
config_opts.update(common_config_opts)
config_opts["literal_binds"] = True
if connectable is None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
config_opts["url"] = config.get_main_option("sqlalchemy.url")
else:
context.configure(
connection=connectable, target_metadata=target_metadata, literal_binds=True
)
config_opts["connection"] = connectable
context.configure(**config_opts)
with context.begin_transaction():
context.run_migrations()
@@ -77,6 +87,8 @@ def run_migrations_online():
"""
connectable = config.attributes.get('connection', None)
config_opts = {}
config_opts.update(common_config_opts)
if connectable is None:
connectable = engine_from_config(
@@ -86,7 +98,10 @@ def run_migrations_online():
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
**common_config_opts,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -11,8 +11,8 @@ down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
def upgrade():

View File

@@ -15,8 +15,8 @@ import logging
logger = logging.getLogger('alembic')
from alembic import op
import sqlalchemy as sa
from alembic import op
tables = ('oauth_access_tokens', 'oauth_codes')

View File

@@ -22,8 +22,9 @@ import logging
logger = logging.getLogger('alembic')
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict

View File

@@ -11,8 +11,9 @@ down_revision = '896818069c98'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict

View File

@@ -11,11 +11,11 @@ down_revision = '1cebaf56856c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import logging
import sqlalchemy as sa
from alembic import op
logger = logging.getLogger('alembic')

View File

@@ -0,0 +1,115 @@
"""api_token_scopes
Revision ID: 651f5419b74d
Revises: 833da8570507
Create Date: 2022-02-28 12:42:55.149046
"""
# revision identifiers, used by Alembic.
revision = '651f5419b74d'
down_revision = '833da8570507'
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy.orm import relationship
from sqlalchemy.orm.session import Session
from jupyterhub import orm, roles, scopes
def upgrade():
c = op.get_bind()
tables = sa.inspect(c.engine).get_table_names()
# oauth codes are short lived, no need to upgrade them
if 'oauth_code_role_map' in tables:
op.drop_table('oauth_code_role_map')
if 'oauth_codes' in tables:
op.add_column('oauth_codes', sa.Column('scopes', orm.JSONList(), nullable=True))
if 'api_tokens' in tables:
# may not be present,
# e.g. upgrade from 1.x, token table dropped
# in which case no migration to do
# define new scopes column on API tokens
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
if 'api_token_role_map' in tables:
# redefine the to-be-removed api_token->role relationship
# so we can run a query on it for the migration
token_role_map = Table(
"api_token_role_map",
orm.Base.metadata,
Column(
'api_token_id',
ForeignKey('api_tokens.id', ondelete='CASCADE'),
primary_key=True,
),
Column(
'role_id',
ForeignKey('roles.id', ondelete='CASCADE'),
primary_key=True,
),
extend_existing=True,
)
orm.APIToken.roles = relationship('Role', secondary='api_token_role_map')
# tokens have roles, evaluate to scopes
db = Session(bind=c)
for token in db.query(orm.APIToken):
token.scopes = list(roles.roles_to_scopes(token.roles))
db.commit()
# drop token-role relationship
op.drop_table('api_token_role_map')
if 'oauth_clients' in tables:
# define new scopes column on API tokens
op.add_column(
'oauth_clients', sa.Column('allowed_scopes', orm.JSONList(), nullable=True)
)
if 'oauth_client_role_map' in tables:
# redefine the to-be-removed api_token->role relationship
# so we can run a query on it for the migration
client_role_map = Table(
"oauth_client_role_map",
orm.Base.metadata,
Column(
'oauth_client_id',
ForeignKey('oauth_clients.id', ondelete='CASCADE'),
primary_key=True,
),
Column(
'role_id',
ForeignKey('roles.id', ondelete='CASCADE'),
primary_key=True,
),
extend_existing=True,
)
orm.OAuthClient.allowed_roles = relationship(
'Role', secondary='oauth_client_role_map'
)
# oauth clients have allowed_roles, evaluate to allowed_scopes
db = Session(bind=c)
for oauth_client in db.query(orm.OAuthClient):
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
allowed_scopes.update(scopes.access_scopes(oauth_client))
oauth_client.allowed_scopes = sorted(allowed_scopes)
db.commit()
# drop token-role relationship
op.drop_table('oauth_client_role_map')
def downgrade():
# cannot map permissions from scopes back to roles
# drop whole api token table (revokes all tokens), which will be recreated on hub start
op.drop_table('api_tokens')
op.drop_table('oauth_clients')
op.drop_table('oauth_codes')

View File

@@ -12,12 +12,11 @@ down_revision = '4dc2d5a8c53c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub import orm
naming_convention = orm.meta.naming_convention

View File

@@ -11,8 +11,8 @@ down_revision = 'd68c98b66cd4'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
def upgrade():

View File

@@ -12,11 +12,11 @@ branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from datetime import datetime
import sqlalchemy as sa
from alembic import op
def upgrade():
op.add_column('users', sa.Column('created', sa.DateTime, nullable=True))

View File

@@ -11,8 +11,8 @@ down_revision = 'eeb276e51423'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
def upgrade():

View File

@@ -11,8 +11,8 @@ down_revision = '99a28a4418e1'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
def upgrade():

View File

@@ -12,8 +12,9 @@ down_revision = '19c0846f6344'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict

View File

@@ -1,9 +1,4 @@
from . import auth
from . import groups
from . import hub
from . import proxy
from . import services
from . import users
from . import auth, groups, hub, proxy, services, users
from .base import *
default_handlers = []

View File

@@ -1,25 +1,16 @@
"""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
from urllib.parse import quote
from urllib.parse import urlencode
from urllib.parse import urlparse
from urllib.parse import urlunparse
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
from oauthlib import oauth2
from tornado import web
from .. import orm
from .. import roles
from .. import scopes
from ..utils import get_browser_protocol
from ..utils import token_authenticated
from .base import APIHandler
from .base import BaseHandler
from .. import orm, roles, scopes
from ..utils import get_browser_protocol, token_authenticated
from .base import APIHandler, BaseHandler
class TokenAPIHandler(APIHandler):
@@ -38,7 +29,7 @@ class TokenAPIHandler(APIHandler):
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.expanded_scopes |= scopes.identify_scopes(owner)
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
# record activity whenever we see a token
@@ -180,7 +171,7 @@ 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, requested_scopes):
"""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
@@ -211,20 +202,20 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
user_id=user.id,
client_id=oauth_client.identifier,
)
authorized_roles = set()
authorized_scopes = set()
for token in existing_tokens:
authorized_roles.update({role.name for role in token.roles})
authorized_scopes.update(token.scopes)
if authorized_roles:
if set(roles).issubset(authorized_roles):
if authorized_scopes:
if set(requested_scopes).issubset(authorized_scopes):
self.log.debug(
f"User {user.name} has already authorized {oauth_client.identifier} for roles {roles}"
f"User {user.name} has already authorized {oauth_client.identifier} for scopes {requested_scopes}"
)
return False
else:
self.log.debug(
f"User {user.name} has authorized {oauth_client.identifier}"
f" for roles {authorized_roles}, confirming additonal roles {roles}"
f" for scopes {authorized_scopes}, confirming additional scopes {requested_scopes}"
)
# default: require confirmation
return True
@@ -251,7 +242,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
uri, http_method, body, headers = self.extract_oauth_params()
try:
(
role_names,
requested_scopes,
credentials,
) = self.oauth_provider.validate_authorization_request(
uri, http_method, body, headers
@@ -283,26 +274,51 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
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):
# subset 'raw scopes' to those held by authenticating user
requested_scopes = set(requested_scopes)
user = self.current_user
# raw, _not_ expanded scopes
user_scopes = roles.roles_to_scopes(roles.get_roles_for(user.orm_user))
# these are some scopes the user may not have
# in 'raw' form, but definitely have at this point
# make sure they are here, because we are computing the
# 'raw' scope intersection,
# rather than the expanded_scope intersection
required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)}
user_scopes |= {"inherit", *required_scopes}
allowed_scopes = requested_scopes.intersection(user_scopes)
excluded_scopes = requested_scopes.difference(user_scopes)
# TODO: compute lower-level intersection of remaining _expanded_ scopes
# (e.g. user has admin:users, requesting read:users!group=x)
if excluded_scopes:
self.log.warning(
f"Service {client.description} requested scopes {','.join(requested_scopes)}"
f" for user {self.current_user.name},"
f" granting only {','.join(allowed_scopes) or '[]'}."
)
if not self.needs_oauth_confirm(self.current_user, client, allowed_scopes):
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, allowed_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:
# discard 'required' scopes from description
# no need to describe the ability to access itself
scopes_to_describe = allowed_scopes.difference(required_scopes)
if not scopes_to_describe:
# TODO: describe all scopes?
# Not right now, because the no-scope default 'identify' text
# is clearer than what we produce for those scopes individually
scope_descriptions = [
{
"scope": None,
@@ -312,8 +328,8 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
"filter": "",
}
]
elif 'inherit' in raw_scopes:
raw_scopes = ['inherit']
elif 'inherit' in scopes_to_describe:
allowed_scopes = scopes_to_describe = ['inherit']
scope_descriptions = [
{
"scope": "inherit",
@@ -325,7 +341,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
]
else:
scope_descriptions = scopes.describe_raw_scopes(
raw_scopes,
scopes_to_describe,
username=self.current_user.name,
)
# Render oauth 'Authorize application...' page
@@ -334,7 +350,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
await self.render_template(
"oauth.html",
auth_state=auth_state,
role_names=role_names,
allowed_scopes=allowed_scopes,
scope_descriptions=scope_descriptions,
oauth_client=client,
)
@@ -381,6 +397,10 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
# The scopes the user actually authorized, i.e. checkboxes
# that were selected.
scopes = self.get_arguments('scopes')
if scopes == []:
# avoid triggering default scopes (provider selects default scopes when scopes is falsy)
# when an explicit empty list is authorized
scopes = ["identify"]
# credentials we need in the validator
credentials = self.add_credentials()

View File

@@ -4,19 +4,15 @@
import json
from functools import lru_cache
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 urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from sqlalchemy.exc import SQLAlchemyError
from tornado import web
from .. import orm
from ..handlers import BaseHandler
from ..utils import get_browser_protocol
from ..utils import isoformat
from ..utils import url_path_join
from ..scopes import get_scopes_for
from ..utils import get_browser_protocol, isoformat, url_escape_path, url_path_join
PAGINATION_MEDIA_TYPE = "application/jupyterhub-pagination+json"
@@ -191,22 +187,44 @@ class APIHandler(BaseHandler):
json.dumps({'status': status_code, 'message': message or status_message})
)
def server_model(self, spawner):
def server_model(self, spawner, *, user=None):
"""Get the JSON model for a Spawner
Assume server permission already granted"""
Assume server permission already granted
"""
if isinstance(spawner, orm.Spawner):
# if an orm.Spawner is passed,
# create a model for a stopped Spawner
# not all info is available without the higher-level Spawner wrapper
orm_spawner = spawner
pending = None
ready = False
stopped = True
user = user
if user is None:
raise RuntimeError("Must specify User with orm.Spawner")
state = orm_spawner.state
else:
orm_spawner = spawner.orm_spawner
pending = spawner.pending
ready = spawner.ready
user = spawner.user
stopped = not spawner.active
state = spawner.get_state()
model = {
'name': spawner.name,
'last_activity': isoformat(spawner.orm_spawner.last_activity),
'started': isoformat(spawner.orm_spawner.started),
'pending': spawner.pending,
'ready': spawner.ready,
'url': url_path_join(spawner.user.url, spawner.name, '/'),
'name': orm_spawner.name,
'last_activity': isoformat(orm_spawner.last_activity),
'started': isoformat(orm_spawner.started),
'pending': pending,
'ready': ready,
'stopped': stopped,
'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
'user_options': spawner.user_options,
'progress_url': spawner._progress_url,
'progress_url': user.progress_url(spawner.name),
}
scope_filter = self.get_scope_filter('admin:server_state')
if scope_filter(spawner, kind='server'):
model['state'] = spawner.get_state()
model['state'] = state
return model
def token_model(self, token):
@@ -224,7 +242,9 @@ class APIHandler(BaseHandler):
owner_key: owner,
'id': token.api_id,
'kind': 'api_token',
'roles': [r.name for r in token.roles],
# deprecated field, but leave it present.
'roles': [],
'scopes': list(get_scopes_for(token)),
'created': isoformat(token.created),
'last_activity': isoformat(token.last_activity),
'expires_at': isoformat(token.expires_at),
@@ -250,10 +270,22 @@ class APIHandler(BaseHandler):
keys.update(allowed_keys)
return model
_include_stopped_servers = None
@property
def include_stopped_servers(self):
"""Whether stopped servers should be included in user models"""
if self._include_stopped_servers is None:
self._include_stopped_servers = self.get_argument(
"include_stopped_servers", "0"
).lower() not in {"0", "false"}
return self._include_stopped_servers
def user_model(self, user):
"""Get the JSON model for a User object"""
if isinstance(user, orm.User):
user = self.users[user.id]
include_stopped_servers = self.include_stopped_servers
model = {
'kind': 'user',
'name': user.name,
@@ -293,18 +325,29 @@ class APIHandler(BaseHandler):
if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending
servers = model['servers'] = {}
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'):
if (spawner.active or include_stopped_servers) and scope_filter(
spawner, kind='server'
):
servers[name] = self.server_model(spawner)
if not servers and 'servers' not in allowed_keys:
if include_stopped_servers:
# add any stopped servers in the db
seen = set(servers.keys())
for name, orm_spawner in user.orm_spawners.items():
if name not in seen and scope_filter(orm_spawner, kind='server'):
servers[name] = self.server_model(orm_spawner, user=user)
if "servers" in allowed_keys or servers:
# omit servers if no access
# leave present and empty
# if request has access to read servers in general
model.pop('servers')
model["servers"] = servers
return model
def group_model(self, group):

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 ..scopes import Scope, needs_scope
from .base import APIHandler
@@ -50,7 +49,7 @@ class GroupListAPIHandler(_GroupAPIHandler):
# 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}"
f"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']))

View File

@@ -47,9 +47,8 @@ class ShutdownAPIHandler(APIHandler):
self.set_status(202)
self.finish(json.dumps({"message": "Shutting down Hub"}))
# stop the eventloop, which will trigger cleanup
loop = IOLoop.current()
loop.add_callback(loop.stop)
# instruct the app to stop, which will trigger cleanup
app.stop()
class RootAPIHandler(APIHandler):

View File

@@ -26,7 +26,7 @@ class ProxyAPIHandler(APIHandler):
else:
routes = {}
end = offset + limit
for i, key in sorted(all_routes.keys()):
for i, key in enumerate(sorted(all_routes.keys())):
if i < offset:
continue
elif i >= end:

View File

@@ -6,8 +6,7 @@ Currently GET-only, no actions can be taken to modify services.
# Distributed under the terms of the Modified BSD License.
import json
from ..scopes import needs_scope
from ..scopes import Scope
from ..scopes import Scope, needs_scope
from .base import APIHandler

View File

@@ -3,26 +3,25 @@
# Distributed under the terms of the Modified BSD License.
import asyncio
import json
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from datetime import datetime, timedelta, timezone
from async_generator import aclosing
from dateutil.parser import parse as parse_date
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy import func, or_
from tornado import web
from tornado.iostream import StreamClosedError
from .. import orm
from .. import scopes
from .. import orm, scopes
from ..roles import assign_default_roles
from ..scopes import needs_scope
from ..user import User
from ..utils import isoformat
from ..utils import iterate_until
from ..utils import maybe_future
from ..utils import url_path_join
from ..utils import (
isoformat,
iterate_until,
maybe_future,
url_escape_path,
url_path_join,
)
from .base import APIHandler
@@ -51,7 +50,7 @@ class SelfAPIHandler(APIHandler):
for scope in identify_scopes:
if scope not in self.expanded_scopes:
_added_scopes.add(scope)
self.expanded_scopes.add(scope)
self.expanded_scopes |= {scope}
if _added_scopes:
# re-parse with new scopes
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
@@ -84,6 +83,7 @@ class UserListAPIHandler(APIHandler):
@needs_scope('list:users')
def get(self):
state_filter = self.get_argument("state", None)
name_filter = self.get_argument("name_filter", None)
offset, limit = self.get_api_pagination()
# post_filter
@@ -130,7 +130,7 @@ class UserListAPIHandler(APIHandler):
if not set(sub_scope).issubset({'group', 'user'}):
# don't expand invalid !server=x filter to all users!
self.log.warning(
"Invalid filter on list:user for {self.current_user}: {sub_scope}"
f"Invalid filter on list:user for {self.current_user}: {sub_scope}"
)
raise web.HTTPError(403)
filters = []
@@ -148,6 +148,9 @@ class UserListAPIHandler(APIHandler):
else:
query = query.filter(or_(*filters))
if name_filter:
query = query.filter(orm.User.name.ilike(f'%{name_filter}%'))
full_query = query
query = query.order_by(orm.User.id.asc()).offset(offset).limit(limit)
@@ -402,21 +405,18 @@ class UserTokenListAPIHandler(APIHandler):
if requester is not user:
note += f" by {kind} {requester.name}"
token_roles = body.get('roles')
token_roles = body.get("roles")
token_scopes = body.get("scopes")
try:
api_token = user.new_api_token(
note=note,
expires_in=body.get('expires_in', None),
roles=token_roles,
scopes=token_scopes,
)
except KeyError:
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
except ValueError:
raise web.HTTPError(
403,
"Requested roles %r cannot have higher permissions than the token owner"
% token_roles,
)
except ValueError as e:
raise web.HTTPError(400, str(e))
if requester is not user:
self.log.info(
"%s %s requested API token for %s",
@@ -691,7 +691,7 @@ class SpawnProgressAPIHandler(APIHandler):
# - spawner not running at all
# - spawner failed
# - spawner pending start (what we expect)
url = url_path_join(user.url, server_name, '/')
url = url_path_join(user.url, url_escape_path(server_name), '/')
ready_event = {
'progress': 100,
'ready': True,

View File

@@ -11,106 +11,85 @@ import re
import secrets
import signal
import socket
import ssl
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from functools import partial
from datetime import datetime, timedelta, timezone
from getpass import getuser
from glob import glob
from itertools import chain
from operator import itemgetter
from textwrap import dedent
from urllib.parse import unquote
from urllib.parse import urlparse
from urllib.parse import urlunparse
from urllib.parse import unquote, urlparse, urlunparse
if sys.version_info[:2] < (3, 3):
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
# For compatibility with python versions 3.6 or earlier.
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
try:
asyncio_all_tasks = asyncio.all_tasks
asyncio_current_task = asyncio.current_task
except AttributeError as e:
asyncio_all_tasks = asyncio.Task.all_tasks
asyncio_current_task = asyncio.Task.current_task
from dateutil.parser import parse as parse_date
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from tornado.httpclient import AsyncHTTPClient
import tornado.httpserver
from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.log import app_log, access_log, gen_log
import tornado.options
from dateutil.parser import parse as parse_date
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
from jupyter_telemetry.eventlog import EventLog
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from tornado import gen, web
from tornado.httpclient import AsyncHTTPClient
from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.log import access_log, app_log, gen_log
from traitlets import (
Unicode,
Integer,
Dict,
TraitError,
List,
Bool,
Any,
Tuple,
Type,
Set,
Instance,
Bool,
Bytes,
Dict,
Float,
Instance,
Integer,
List,
Set,
Tuple,
Unicode,
Union,
observe,
default,
observe,
validate,
)
from traitlets.config import Application, Configurable, catch_config_error
from jupyter_telemetry.eventlog import EventLog
here = os.path.dirname(__file__)
import jupyterhub
from . import handlers, apihandlers
from .handlers.static import CacheControlStaticFilesHandler, LogoHandler
from .services.service import Service
from . import crypto
from . import dbutil, orm
from . import roles
from .user import UserDict
from .oauth.provider import make_provider
from . import apihandlers, crypto, dbutil, handlers, orm, roles, scopes
from ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request
from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import (
AnyTimeoutError,
catch_db_error,
maybe_future,
url_path_join,
print_stacks,
print_ps_info,
make_ssl_context,
)
from .metrics import HUB_STARTUP_DURATION_SECONDS
from .metrics import INIT_SPAWNERS_DURATION_SECONDS
from .metrics import RUNNING_SERVERS
from .metrics import TOTAL_USERS
# classes for config
from .auth import Authenticator, PAMAuthenticator
from .crypto import CryptKeeper
from .spawner import Spawner, LocalProcessSpawner
from .objects import Hub, Server
# For faking stats
from .emptyclass import EmptyClass
from .handlers.static import CacheControlStaticFilesHandler, LogoHandler
from .log import CoroutineLogFormatter, log_request
from .metrics import (
HUB_STARTUP_DURATION_SECONDS,
INIT_SPAWNERS_DURATION_SECONDS,
RUNNING_SERVERS,
TOTAL_USERS,
)
from .oauth.provider import make_provider
from .objects import Hub, Server
from .proxy import ConfigurableHTTPProxy, Proxy
from .services.service import Service
from .spawner import LocalProcessSpawner, Spawner
from .traitlets import Callable, Command, EntryPointType, URLPrefix
from .user import UserDict
from .utils import (
AnyTimeoutError,
catch_db_error,
make_ssl_context,
maybe_future,
print_ps_info,
print_stacks,
url_path_join,
)
common_aliases = {
'log-level': 'Application.log_level',
@@ -353,6 +332,29 @@ class JupyterHub(Application):
""",
).tag(config=True)
custom_scopes = Dict(
key_trait=Unicode(),
value_trait=Dict(
key_trait=Unicode(),
),
help="""Custom scopes to define.
For use when defining custom roles,
to grant users granular permissions
All custom scopes must have a description,
and must start with the prefix `custom:`.
For example::
custom_scopes = {
"custom:jupyter_server:read": {
"description": "read-only access to a single-user server",
},
}
""",
).tag(config=True)
config_file = Unicode('jupyterhub_config.py', help="The config file to load").tag(
config=True
)
@@ -701,11 +703,14 @@ class JupyterHub(Application):
""",
).tag(config=True)
def _subdomain_host_changed(self, name, old, new):
@validate("subdomain_host")
def _validate_subdomain_host(self, proposal):
new = proposal.value
if new and '://' not in new:
# host should include '://'
# if not specified, assume https: You have to be really explicit about HTTP!
self.subdomain_host = 'https://' + new
new = 'https://' + new
return new
domain = Unicode(help="domain name, e.g. 'example.com' (excludes protocol, port)")
@@ -1119,7 +1124,7 @@ class JupyterHub(Application):
@default('authenticator')
def _authenticator_default(self):
return self.authenticator_class(parent=self, db=self.db)
return self.authenticator_class(parent=self, _deprecated_db_session=self.db)
implicit_spawn_seconds = Float(
0,
@@ -1307,11 +1312,14 @@ class JupyterHub(Application):
admin_access = Bool(
False,
help="""Grant admin users permission to access single-user servers.
help="""DEPRECATED since version 2.0.0.
Users should be properly informed if this is enabled.
The default admin role has full permissions, use custom RBAC scopes instead to
create restricted administrator roles.
https://jupyterhub.readthedocs.io/en/stable/rbac/index.html
""",
).tag(config=True)
admin_users = Set(
help="""DEPRECATED since version 0.7.2, use Authenticator.admin_users instead."""
).tag(config=True)
@@ -1689,7 +1697,9 @@ class JupyterHub(Application):
for authority, files in self.internal_ssl_authorities.items():
if files:
self.log.info("Adding CA for %s", authority)
certipy.store.add_record(authority, is_ca=True, files=files)
certipy.store.add_record(
authority, is_ca=True, files=files, overwrite=True
)
self.internal_trust_bundles = certipy.trust_from_graph(
self.internal_ssl_components_trust
@@ -2018,7 +2028,10 @@ class JupyterHub(Application):
db.commit()
async def init_role_creation(self):
"""Load default and predefined roles into the database"""
"""Load default and user-defined roles and scopes into the database"""
if self.custom_scopes:
self.log.info(f"Defining {len(self.custom_scopes)} custom scopes.")
scopes.define_custom_scopes(self.custom_scopes)
self.log.debug('Loading roles into database')
default_roles = roles.get_default_roles()
config_role_names = [r['name'] for r in self.load_roles]
@@ -2055,23 +2068,6 @@ class JupyterHub(Application):
# make sure we load any default roles not overridden
init_roles = list(default_roles_dict.values()) + init_roles
if roles_with_new_permissions:
unauthorized_oauth_tokens = (
self.db.query(orm.APIToken)
.filter(
orm.APIToken.roles.any(
orm.Role.name.in_(roles_with_new_permissions)
)
)
.filter(orm.APIToken.client_id != 'jupyterhub')
)
for token in unauthorized_oauth_tokens:
self.log.warning(
"Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
% token
)
self.db.delete(token)
self.db.commit()
init_role_names = [r['name'] for r in init_roles]
if (
@@ -2207,9 +2203,6 @@ class JupyterHub(Application):
for kind in kinds:
roles.check_for_default_roles(db, kind)
# check tokens for default roles
roles.check_for_default_roles(db, bearer='tokens')
async def _add_tokens(self, token_dict, kind):
"""Add tokens for users or services to the database"""
if kind == 'user':
@@ -2374,21 +2367,34 @@ class JupyterHub(Application):
service.orm.server = None
if service.oauth_available:
allowed_roles = []
allowed_scopes = set()
if service.oauth_client_allowed_scopes:
allowed_scopes.update(service.oauth_client_allowed_scopes)
if service.oauth_roles:
allowed_roles = list(
self.db.query(orm.Role).filter(
orm.Role.name.in_(service.oauth_roles)
if not allowed_scopes:
# DEPRECATED? It's still convenient and valid,
# e.g. 'admin'
allowed_roles = list(
self.db.query(orm.Role).filter(
orm.Role.name.in_(service.oauth_roles)
)
)
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
else:
self.log.warning(
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
f" using oauth_client_allowed_scopes={allowed_scopes}."
)
)
oauth_client = self.oauth_provider.add_client(
client_id=service.oauth_client_id,
client_secret=service.api_token,
redirect_uri=service.oauth_redirect_uri,
allowed_roles=allowed_roles,
description="JupyterHub service %s" % service.name,
)
service.orm.oauth_client = oauth_client
# add access-scopes, derived from OAuthClient itself
allowed_scopes.update(scopes.access_scopes(oauth_client))
oauth_client.allowed_scopes = sorted(allowed_scopes)
else:
if service.oauth_client:
self.db.delete(service.oauth_client)
@@ -3058,7 +3064,7 @@ class JupyterHub(Application):
self.internal_ssl_key,
self.internal_ssl_cert,
cafile=self.internal_ssl_ca,
check_hostname=False,
purpose=ssl.Purpose.CLIENT_AUTH,
)
# start the webserver
@@ -3235,16 +3241,18 @@ class JupyterHub(Application):
self._atexit_ran = True
self._init_asyncio_patch()
# run the cleanup step (in a new loop, because the interrupted one is unclean)
asyncio.set_event_loop(asyncio.new_event_loop())
IOLoop.clear_current()
loop = IOLoop()
loop.make_current()
loop.run_sync(self.cleanup)
asyncio.run(self.cleanup())
async def shutdown_cancel_tasks(self, sig):
async def shutdown_cancel_tasks(self, sig=None):
"""Cancel all other tasks of the event loop and initiate cleanup"""
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
if sig is None:
self.log.critical("Initiating shutdown...")
else:
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
await self.cleanup()
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
if tasks:
self.log.debug("Cancelling pending tasks")
@@ -3257,10 +3265,9 @@ class JupyterHub(Application):
except StopAsyncIteration as e:
self.log.error("Caught StopAsyncIteration Exception", exc_info=True)
tasks = [t for t in asyncio_all_tasks()]
tasks = [t for t in asyncio.all_tasks()]
for t in tasks:
self.log.debug("Task status: %s", t)
await self.cleanup()
asyncio.get_event_loop().stop()
def stop(self):
@@ -3268,7 +3275,7 @@ class JupyterHub(Application):
return
if self.http_server:
self.http_server.stop()
self.io_loop.add_callback(self.io_loop.stop)
self.io_loop.add_callback(self.shutdown_cancel_tasks)
async def start_show_config(self):
"""Async wrapper around base start_show_config method"""
@@ -3294,16 +3301,19 @@ class JupyterHub(Application):
def launch_instance(cls, argv=None):
self = cls.instance()
self._init_asyncio_patch()
loop = IOLoop.current()
task = asyncio.ensure_future(self.launch_instance_async(argv))
loop = IOLoop(make_current=False)
try:
loop.run_sync(self.launch_instance_async, argv)
except Exception:
loop.close()
raise
try:
loop.start()
except KeyboardInterrupt:
print("\nInterrupted")
finally:
if task.done():
# re-raise exceptions in launch_instance_async
task.result()
loop.stop()
loop.close()

View File

@@ -9,9 +9,8 @@ import warnings
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from shutil import which
from subprocess import PIPE
from subprocess import Popen
from subprocess import STDOUT
from subprocess import PIPE, STDOUT, Popen
from textwrap import dedent
try:
import pamela
@@ -20,13 +19,12 @@ except Exception as e:
_pamela_error = e
from tornado.concurrent import run_on_executor
from traitlets import Any, Bool, Dict, Integer, Set, Unicode, default, observe
from traitlets.config import LoggingConfigurable
from traitlets import Bool, Integer, Set, Unicode, Dict, Any, default, observe
from .handlers.login import LoginHandler
from .utils import maybe_future, url_path_join
from .traitlets import Command
from .utils import maybe_future, url_path_join
class Authenticator(LoggingConfigurable):
@@ -34,6 +32,23 @@ class Authenticator(LoggingConfigurable):
db = Any()
@default("db")
def _deprecated_db(self):
self.log.warning(
dedent(
"""
The shared database session at Authenticator.db is deprecated, and will be removed.
Please manage your own database and connections.
Contact JupyterHub at https://github.com/jupyterhub/jupyterhub/issues/3700
if you have questions or ideas about direct database needs for your Authenticator.
"""
),
)
return self._deprecated_db_session
_deprecated_db_session = Any()
enable_auth_state = Bool(
False,
config=True,
@@ -241,6 +256,9 @@ class Authenticator(LoggingConfigurable):
if not username:
# empty usernames are not allowed
return False
if username != username.strip():
# starting/ending with space is not allowed
return False
if not self.username_regex:
return True
return bool(self.username_regex.match(username))
@@ -817,7 +835,7 @@ class LocalAuthenticator(Authenticator):
raise ValueError("I don't know how to create users on OS X")
elif which('pw'):
# Probably BSD
return ['pw', 'useradd', '-m']
return ['pw', 'useradd', '-m', '-n']
else:
# This appears to be the Linux non-interactive adduser command:
return ['adduser', '-q', '--gecos', '""', '--disabled-password']

View File

@@ -4,18 +4,12 @@ import os
from binascii import a2b_hex
from concurrent.futures import ThreadPoolExecutor
from traitlets import Any
from traitlets import default
from traitlets import Integer
from traitlets import List
from traitlets import observe
from traitlets import validate
from traitlets.config import Config
from traitlets.config import SingletonConfigurable
from traitlets import Any, Integer, List, default, observe, validate
from traitlets.config import Config, SingletonConfigurable
try:
import cryptography
from cryptography.fernet import Fernet, MultiFernet, InvalidToken
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
except ImportError:
cryptography = None

View File

@@ -1,7 +1,4 @@
from . import base
from . import login
from . import metrics
from . import pages
from . import base, login, metrics, pages
from .base import *
from .login import *

View File

@@ -9,50 +9,44 @@ import random
import re
import time
import uuid
from datetime import datetime
from datetime import timedelta
from datetime import datetime, timedelta
from http.client import responses
from urllib.parse import parse_qs
from urllib.parse import parse_qsl
from urllib.parse import urlencode
from urllib.parse import urlparse
from urllib.parse import urlunparse
from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse, urlunparse
from jinja2 import TemplateNotFound
from sqlalchemy.exc import SQLAlchemyError
from tornado import gen
from tornado import web
from tornado.httputil import HTTPHeaders
from tornado.httputil import url_concat
from tornado import gen, web
from tornado.httputil import HTTPHeaders, url_concat
from tornado.ioloop import IOLoop
from tornado.log import app_log
from tornado.web import addslash
from tornado.web import RequestHandler
from tornado.web import RequestHandler, addslash
from .. import __version__
from .. import orm
from .. import roles
from .. import scopes
from ..metrics import PROXY_ADD_DURATION_SECONDS
from ..metrics import PROXY_DELETE_DURATION_SECONDS
from ..metrics import ProxyDeleteStatus
from ..metrics import RUNNING_SERVERS
from ..metrics import SERVER_POLL_DURATION_SECONDS
from ..metrics import SERVER_SPAWN_DURATION_SECONDS
from ..metrics import SERVER_STOP_DURATION_SECONDS
from ..metrics import ServerPollStatus
from ..metrics import ServerSpawnStatus
from ..metrics import ServerStopStatus
from ..metrics import TOTAL_USERS
from .. import __version__, orm, roles, scopes
from ..metrics import (
PROXY_ADD_DURATION_SECONDS,
PROXY_DELETE_DURATION_SECONDS,
RUNNING_SERVERS,
SERVER_POLL_DURATION_SECONDS,
SERVER_SPAWN_DURATION_SECONDS,
SERVER_STOP_DURATION_SECONDS,
TOTAL_USERS,
ProxyDeleteStatus,
ServerPollStatus,
ServerSpawnStatus,
ServerStopStatus,
)
from ..objects import Server
from ..scopes import needs_scope
from ..spawner import LocalProcessSpawner
from ..user import User
from ..utils import AnyTimeoutError
from ..utils import get_accepted_mimetype
from ..utils import get_browser_protocol
from ..utils import maybe_future
from ..utils import url_path_join
from ..utils import (
AnyTimeoutError,
get_accepted_mimetype,
get_browser_protocol,
maybe_future,
url_escape_path,
url_path_join,
)
# pattern for the authentication token header
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
@@ -529,7 +523,7 @@ class BaseHandler(RequestHandler):
# clear_cookie only accepts a subset of set_cookie's kwargs
clear_xsrf_cookie_kwargs = {
key: value
for key, value in self.settings.get('xsrf_cookie_kwargs', {})
for key, value in self.settings.get('xsrf_cookie_kwargs', {}).items()
if key in {"path", "domain"}
}
@@ -642,29 +636,32 @@ class BaseHandler(RequestHandler):
next_url = next_url.replace('\\', '%5C')
proto = get_browser_protocol(self.request)
host = self.request.host
if next_url.startswith("///"):
# strip more than 2 leading // down to 2
# because urlparse treats that as empty netloc,
# whereas browsers treat more than two leading // the same as //,
# so netloc is the first non-/ bit
next_url = "//" + next_url.lstrip("/")
parsed_next_url = urlparse(next_url)
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
self.subdomain_host
and urlparse(next_url).netloc
and ("." + urlparse(next_url).netloc).endswith(
and parsed_next_url.netloc
and ("." + parsed_next_url.netloc).endswith(
"." + urlparse(self.subdomain_host).netloc
)
):
# treat absolute URLs for our host as absolute paths:
# below, redirects that aren't strictly paths
parsed = urlparse(next_url)
next_url = parsed.path
if parsed.query:
next_url = next_url + '?' + parsed.query
if parsed.fragment:
next_url = next_url + '#' + parsed.fragment
# below, redirects that aren't strictly paths are rejected
next_url = parsed_next_url.path
if parsed_next_url.query:
next_url = next_url + '?' + parsed_next_url.query
if parsed_next_url.fragment:
next_url = next_url + '#' + parsed_next_url.fragment
parsed_next_url = urlparse(next_url)
# if it still has host info, it didn't match our above check for *this* host
if next_url and (
'://' in next_url
or next_url.startswith('//')
or not next_url.startswith('/')
):
if next_url and (parsed_next_url.netloc or not next_url.startswith('/')):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = ''
@@ -859,6 +856,12 @@ class BaseHandler(RequestHandler):
user_server_name = user.name
if server_name:
if '/' in server_name:
error_message = (
f"Invalid server_name (may not contain '/'): {server_name}"
)
self.log.error(error_message)
raise web.HTTPError(400, error_message)
user_server_name = f'{user.name}:{server_name}'
if server_name in user.spawners and user.spawners[server_name].pending:
@@ -1517,6 +1520,7 @@ class UserUrlHandler(BaseHandler):
server_name = ''
else:
server_name = ''
escaped_server_name = url_escape_path(server_name)
spawner = user.spawners[server_name]
if spawner.ready:
@@ -1535,7 +1539,10 @@ class UserUrlHandler(BaseHandler):
pending_url = url_concat(
url_path_join(
self.hub.base_url, 'spawn-pending', user.escaped_name, server_name
self.hub.base_url,
'spawn-pending',
user.escaped_name,
escaped_server_name,
),
{'next': self.request.uri},
)
@@ -1549,7 +1556,9 @@ class UserUrlHandler(BaseHandler):
# page *in* the server is not found, we return a 424 instead of a 404.
# We allow retaining the old behavior to support older JupyterLab versions
spawn_url = url_concat(
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
url_path_join(
self.hub.base_url, "spawn", user.escaped_name, escaped_server_name
),
{"next": self.request.uri},
)
self.set_status(

View File

@@ -145,7 +145,9 @@ class LoginHandler(BaseHandler):
# parse the arguments dict
data = {}
for arg in self.request.arguments:
data[arg] = self.get_argument(arg, strip=False)
# strip username, but not other fields like passwords,
# which should be allowed to start or end with space
data[arg] = self.get_argument(arg, strip=arg == "username")
auth_timer = self.statsd.timer('login.authenticate').start()
user = await self.login_user(data)

View File

@@ -1,7 +1,5 @@
"""Handlers for serving prometheus metrics"""
from prometheus_client import CONTENT_TYPE_LATEST
from prometheus_client import generate_latest
from prometheus_client import REGISTRY
from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest
from ..utils import metrics_authentication
from .base import BaseHandler

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