diff --git a/.circleci/config.yml b/.circleci/config.yml index fb744df7..2f4a568a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,51 +24,6 @@ jobs: command: | docker run --rm -it -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py - docs: - # This is the base environment that Circle will use - docker: - - image: circleci/python:3.6-stretch - steps: - # Get our data and merge with upstream - - run: sudo apt-get update - - checkout - # Update our path - - run: echo "export PATH=~/.local/bin:$PATH" >> $BASH_ENV - # Restore cached files to speed things up - - restore_cache: - keys: - - cache-pip - # Install the packages needed to build our documentation - - run: - name: Install NodeJS - command: | - # From https://github.com/nodesource/distributions/blob/master/README.md#debinstall - curl -sL https://deb.nodesource.com/setup_13.x | sudo -E bash - - sudo apt-get install -y nodejs - - - run: - name: Install dependencies - command: | - python3 -m pip install --user -r dev-requirements.txt - python3 -m pip install --user -r docs/requirements.txt - sudo npm install -g configurable-http-proxy - sudo python3 -m pip install --editable . - - # Cache some files for a speedup in subsequent builds - - save_cache: - key: cache-pip - paths: - - ~/.cache/pip - # Build the docs - - run: - name: Build docs to store - command: | - cd docs - make html - # Tell Circle to store the documentation output in a folder that we can access later - - store_artifacts: - path: docs/build/html/ - destination: html # Tell CircleCI to use this workflow when it builds the site workflows: @@ -76,4 +31,3 @@ workflows: default: jobs: - build - - docs diff --git a/docs/rest-api.yml b/docs/rest-api.yml index 028c895e..beb5e181 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -16,34 +16,34 @@ securityDefinitions: oauth2: type: oauth2 flow: accessCode - authorizationUrl: 'https://localhost:8000/hub/api/oauth2/authorize' # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations? - tokenUrl: 'https://localhost:8000/hub/api/oauth2/token' + authorizationUrl: '/hub/api/oauth2/authorize' # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations? + tokenUrl: '/hub/api/oauth2/token' scopes: all: Everything a user can do read:all: Read-only access to everything a user can read (also whoami handler) users: Grants access to managing users including reading users’ model, posting activity and starting/stoping users servers read:users: Read-only access to the above - read:users:username: Read-only access to a single user's model + read:users!user=username: Read-only access to a single user's model read:users:names: Read-only access to users' names read:users:groups: Read-only access to users' groups read:users:activity: Read-only access to users' activity - read:users:activity:groupname: Read-only access to specific group's users' activity + read:users:activity!group=groupname: Read-only access to specific group's users' activity read:users:servers: Read-only access to users' servers - users:activity:username: Update a user's activity + users:activity!user=username: Update a user's activity users:servers: Grants access to start/stop any server - users:servers:servername: Limits the above to a specific server + users:servers!server=servername: Limits the above to a specific server users:tokens: Grants access to users' token (includes create/revoke a token) read:users:tokens: Identify a user from a token admin:users: Grants access to creating/removing users admin:users:servers: Grants access to create/remove users' servers groups: Add/remove users from any group - groups:groupname: Add/remove users from a specific group only + groups!group=groupname: Add/remove users from a specific group only read:groups: Read-only access to groups admin:groups: Grants access to create/delete groups read:services: Read-only access to services proxy: Grants access to proxy's routing table, syncing and notifying about a new proxy shutdown: Grants access to shutdown the Hub -security: # global security, do we want to keep the only the apiKey (token: []), change to only oauth2 (with scope all) or have both as changed here (either can be used)? +security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope all) or have both (either can be used)? - token: [] - oauth2: - all @@ -157,7 +157,7 @@ paths: - oauth2: - users - read:users - - read:users:username + - read:users!user=username parameters: - name: name description: username @@ -240,7 +240,7 @@ paths: security: - oauth2: - users - - users:activity:username + - users:activity!user=username parameters: - name: name description: username @@ -345,7 +345,7 @@ paths: - oauth2: - users - users:servers - - users:servers:servername + - users:servers!server=servername parameters: - name: name description: username @@ -381,7 +381,7 @@ paths: - oauth2: - users - users:servers - - users:servers:servername + - users:servers!server=servername parameters: - name: name description: username @@ -519,7 +519,7 @@ paths: security: - oauth2: - groups - - groups:groupname + - groups!group=groupname - read:groups parameters: - name: name @@ -568,7 +568,7 @@ paths: security: - oauth2: - groups - - groups:groupname + - groups!group=groupname parameters: - name: name description: group name @@ -597,7 +597,7 @@ paths: security: - oauth2: - groups - - groups:groupname + - groups!group=groupname parameters: - name: name description: group name @@ -768,7 +768,7 @@ paths: $ref: '#/definitions/User' '404': description: A user is not found. - # deprecated: true # minrk: let’s not add a scope for this, let’s remove it + deprecated: true # minrk: let’s not add a scope for this, let’s remove it /oauth2/authorize: get: summary: 'OAuth 2.0 authorize endpoint' @@ -886,6 +886,11 @@ definitions: admin: type: boolean description: Whether the user is an admin + roles: + type: array + description: The names of roles this user has + items: + type: string groups: type: array description: The names of groups where this user is a member diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 6eb740e7..d5f12850 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -7,6 +7,152 @@ command line for details. ## [Unreleased] +## 1.2 + +### 1.2.0b1 + +JupyterHub 1.2 is an incremental release with lots of small improvements. +It is unlikely that users will have to change much to upgrade, +but lots of new things are possible and/or better! + +There are no database schema changes requiring migration from 1.1 to 1.2. + +Highlights: + +- Deprecate black/whitelist configuration fields in favor of more inclusive blocked/allowed language +- More configuration of page templates and service display +- Pagination of the admin page improving performance with large numbers of users +- Improved control of user redirect with +- Support for [jupyter-server](https://jupyter-server.readthedocs.io/en/latest/)-based single-user servers, such as [Voilà](https://voila-gallery.org) and latest JupyterLab. +- Lots more improvements to documentation, HTML pages, and customizations + + + + + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/1.1.0...1.2.0b1)) + + +#### Enhancements made +* Control service display [#3160](https://github.com/jupyterhub/jupyterhub/pull/3160) ([@rcthomas](https://github.com/rcthomas)) +* Add a footer block + wrap the admin footer in this block [#3136](https://github.com/jupyterhub/jupyterhub/pull/3136) ([@pabepadu](https://github.com/pabepadu)) +* Allow JupyterHub.default_url to be a callable [#3133](https://github.com/jupyterhub/jupyterhub/pull/3133) ([@danlester](https://github.com/danlester)) +* Allow head requests for the health endpoint [#3131](https://github.com/jupyterhub/jupyterhub/pull/3131) ([@rkevin-arch](https://github.com/rkevin-arch)) +* Hide hamburger button menu in mobile/responsive mode and fix other minor issues [#3103](https://github.com/jupyterhub/jupyterhub/pull/3103) ([@kinow](https://github.com/kinow)) +* build jupyterhub/jupyterhub-demo image on docker hub [#3083](https://github.com/jupyterhub/jupyterhub/pull/3083) ([@minrk](https://github.com/minrk)) +* Add JupyterHub Demo docker image [#3059](https://github.com/jupyterhub/jupyterhub/pull/3059) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +* Warn if both bind_url and ip/port/base_url are set [#3057](https://github.com/jupyterhub/jupyterhub/pull/3057) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +* UI Feedback on Submit [#3028](https://github.com/jupyterhub/jupyterhub/pull/3028) ([@possiblyMikeB](https://github.com/possiblyMikeB)) +* Support kubespawner running on a IPv6 only cluster [#3020](https://github.com/jupyterhub/jupyterhub/pull/3020) ([@stv0g](https://github.com/stv0g)) +* Spawn with options passed in query arguments to /spawn [#3013](https://github.com/jupyterhub/jupyterhub/pull/3013) ([@twalcari](https://github.com/twalcari)) +* SpawnHandler POST with user form options displays the spawn-pending page [#2978](https://github.com/jupyterhub/jupyterhub/pull/2978) ([@danlester](https://github.com/danlester)) +* Start named servers by pressing the Enter key [#2960](https://github.com/jupyterhub/jupyterhub/pull/2960) ([@jtpio](https://github.com/jtpio)) +* Keep the URL fragments after spawning an application [#2952](https://github.com/jupyterhub/jupyterhub/pull/2952) ([@kinow](https://github.com/kinow)) +* Allow implicit spawn via javascript redirect [#2941](https://github.com/jupyterhub/jupyterhub/pull/2941) ([@minrk](https://github.com/minrk)) +* make init_spawners check O(running servers) not O(total users) [#2936](https://github.com/jupyterhub/jupyterhub/pull/2936) ([@minrk](https://github.com/minrk)) +* Add favicon to the base page template [#2930](https://github.com/jupyterhub/jupyterhub/pull/2930) ([@JohnPaton](https://github.com/JohnPaton)) +* Adding pagination in the admin panel [#2929](https://github.com/jupyterhub/jupyterhub/pull/2929) ([@cbjuan](https://github.com/cbjuan)) +* Generate prometheus metrics docs [#2891](https://github.com/jupyterhub/jupyterhub/pull/2891) ([@rajat404](https://github.com/rajat404)) +* Add support for Jupyter Server [#2601](https://github.com/jupyterhub/jupyterhub/pull/2601) ([@yuvipanda](https://github.com/yuvipanda)) + +#### Bugs fixed +* avoid specifying default_value=None in Command traits [#3208](https://github.com/jupyterhub/jupyterhub/pull/3208) ([@minrk](https://github.com/minrk)) +* Prevent OverflowErrors in exponential_backoff() [#3204](https://github.com/jupyterhub/jupyterhub/pull/3204) ([@kreuzert](https://github.com/kreuzert)) +* update prometheus metrics for server spawn when it fails with exception [#3150](https://github.com/jupyterhub/jupyterhub/pull/3150) ([@yhal-nesi](https://github.com/yhal-nesi)) +* jupyterhub/utils: Load system default CA certificates in make_ssl_context [#3140](https://github.com/jupyterhub/jupyterhub/pull/3140) ([@chancez](https://github.com/chancez)) +* admin page sorts on spawner last_activity instead of user last_activity [#3137](https://github.com/jupyterhub/jupyterhub/pull/3137) ([@lydian](https://github.com/lydian)) +* Fix the services dropdown on the admin page [#3132](https://github.com/jupyterhub/jupyterhub/pull/3132) ([@pabepadu](https://github.com/pabepadu)) +* Don't log a warning when slow_spawn_timeout is disabled [#3127](https://github.com/jupyterhub/jupyterhub/pull/3127) ([@mriedem](https://github.com/mriedem)) +* app.py: Work around incompatibility between Tornado 6 and asyncio proactor event loop in python 3.8 on Windows [#3123](https://github.com/jupyterhub/jupyterhub/pull/3123) ([@alexweav](https://github.com/alexweav)) +* jupyterhub/user: clear spawner state after post_stop_hook [#3121](https://github.com/jupyterhub/jupyterhub/pull/3121) ([@rkdarst](https://github.com/rkdarst)) +* fix for stopping named server deleting default server and tests [#3109](https://github.com/jupyterhub/jupyterhub/pull/3109) ([@kxiao-fn](https://github.com/kxiao-fn)) +* Hide hamburger button menu in mobile/responsive mode and fix other minor issues [#3103](https://github.com/jupyterhub/jupyterhub/pull/3103) ([@kinow](https://github.com/kinow)) +* Rename Authenticator.white/blacklist to allowed/blocked [#3090](https://github.com/jupyterhub/jupyterhub/pull/3090) ([@minrk](https://github.com/minrk)) +* Include the query string parameters when redirecting to a new URL [#3089](https://github.com/jupyterhub/jupyterhub/pull/3089) ([@kinow](https://github.com/kinow)) +* Make `delete_invalid_users` configurable [#3087](https://github.com/jupyterhub/jupyterhub/pull/3087) ([@fcollonval](https://github.com/fcollonval)) +* Ensure client dependencies build before wheel [#3082](https://github.com/jupyterhub/jupyterhub/pull/3082) ([@diurnalist](https://github.com/diurnalist)) +* make Spawner.environment config highest priority [#3081](https://github.com/jupyterhub/jupyterhub/pull/3081) ([@minrk](https://github.com/minrk)) +* Changing start my server button link to spawn url once server is stopped [#3042](https://github.com/jupyterhub/jupyterhub/pull/3042) ([@rabsr](https://github.com/rabsr)) +* Fix CSS on admin page version listing [#3035](https://github.com/jupyterhub/jupyterhub/pull/3035) ([@vilhelmen](https://github.com/vilhelmen)) +* Fix user_row endblock in admin template [#3015](https://github.com/jupyterhub/jupyterhub/pull/3015) ([@jtpio](https://github.com/jtpio)) +* Fix --generate-config bug when specifying a filename [#2907](https://github.com/jupyterhub/jupyterhub/pull/2907) ([@consideRatio](https://github.com/consideRatio)) +* Handle the protocol when ssl is enabled and log the right URL [#2773](https://github.com/jupyterhub/jupyterhub/pull/2773) ([@kinow](https://github.com/kinow)) + +#### Maintenance and upkeep improvements +* stop building docs on circleci [#3209](https://github.com/jupyterhub/jupyterhub/pull/3209) ([@minrk](https://github.com/minrk)) +* Upgraded Jquery dep [#3174](https://github.com/jupyterhub/jupyterhub/pull/3174) ([@AngelOnFira](https://github.com/AngelOnFira)) +* Don't allow 'python:3.8 + master dependencies' to fail [#3157](https://github.com/jupyterhub/jupyterhub/pull/3157) ([@manics](https://github.com/manics)) +* Update Dockerfile to ubuntu:focal (Python 3.8) [#3156](https://github.com/jupyterhub/jupyterhub/pull/3156) ([@manics](https://github.com/manics)) +* Simplify code of the health check handler [#3149](https://github.com/jupyterhub/jupyterhub/pull/3149) ([@betatim](https://github.com/betatim)) +* Get error description from error key vs error_description key [#3147](https://github.com/jupyterhub/jupyterhub/pull/3147) ([@jgwerner](https://github.com/jgwerner)) +* Implement singleuser with mixins [#3128](https://github.com/jupyterhub/jupyterhub/pull/3128) ([@minrk](https://github.com/minrk)) +* only build tagged versions on docker tags [#3118](https://github.com/jupyterhub/jupyterhub/pull/3118) ([@minrk](https://github.com/minrk)) +* Log slow_stop_timeout when hit like slow_spawn_timeout [#3111](https://github.com/jupyterhub/jupyterhub/pull/3111) ([@mriedem](https://github.com/mriedem)) +* loosen jupyter-telemetry pin [#3102](https://github.com/jupyterhub/jupyterhub/pull/3102) ([@minrk](https://github.com/minrk)) +* Remove old context-less print statement [#3100](https://github.com/jupyterhub/jupyterhub/pull/3100) ([@mriedem](https://github.com/mriedem)) +* Allow `python:3.8 + master dependencies` to fail [#3079](https://github.com/jupyterhub/jupyterhub/pull/3079) ([@manics](https://github.com/manics)) +* Test with some master dependencies. [#3076](https://github.com/jupyterhub/jupyterhub/pull/3076) ([@Carreau](https://github.com/Carreau)) +* synchronize implementation of expiring values [#3072](https://github.com/jupyterhub/jupyterhub/pull/3072) ([@minrk](https://github.com/minrk)) +* More consistent behavior for UserDict.get and `key in UserDict` [#3071](https://github.com/jupyterhub/jupyterhub/pull/3071) ([@minrk](https://github.com/minrk)) +* pin jupyter_telemetry dependency [#3067](https://github.com/jupyterhub/jupyterhub/pull/3067) ([@Zsailer](https://github.com/Zsailer)) +* Use the issue templates from the central repo [#3056](https://github.com/jupyterhub/jupyterhub/pull/3056) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +* Update links to the black GitHub repository [#3054](https://github.com/jupyterhub/jupyterhub/pull/3054) ([@jtpio](https://github.com/jtpio)) +* Log successful /health requests as debug level [#3047](https://github.com/jupyterhub/jupyterhub/pull/3047) ([@consideRatio](https://github.com/consideRatio)) +* Fix broken test due to BeautifulSoup 4.9.0 behavior change [#3025](https://github.com/jupyterhub/jupyterhub/pull/3025) ([@twalcari](https://github.com/twalcari)) +* Remove unused imports [#3019](https://github.com/jupyterhub/jupyterhub/pull/3019) ([@stv0g](https://github.com/stv0g)) +* Use pip instead of conda for building the docs on RTD [#3010](https://github.com/jupyterhub/jupyterhub/pull/3010) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +* Avoid redundant logging of jupyterhub version mismatches [#2971](https://github.com/jupyterhub/jupyterhub/pull/2971) ([@mriedem](https://github.com/mriedem)) +* Add .vscode to gitignore [#2959](https://github.com/jupyterhub/jupyterhub/pull/2959) ([@jtpio](https://github.com/jtpio)) +* preserve auth type when logging obfuscated auth header [#2953](https://github.com/jupyterhub/jupyterhub/pull/2953) ([@minrk](https://github.com/minrk)) +* make spawner:server relationship explicitly one to one [#2944](https://github.com/jupyterhub/jupyterhub/pull/2944) ([@minrk](https://github.com/minrk)) +* Add what we need with some margin to Dockerfile's build stage [#2905](https://github.com/jupyterhub/jupyterhub/pull/2905) ([@consideRatio](https://github.com/consideRatio)) +* bump reorder-imports hook [#2899](https://github.com/jupyterhub/jupyterhub/pull/2899) ([@minrk](https://github.com/minrk)) + +#### Documentation improvements +* [docs] Remove duplicate line in changelog for 1.1.0 [#3207](https://github.com/jupyterhub/jupyterhub/pull/3207) ([@kinow](https://github.com/kinow)) +* Add SELinux configuration for nginx [#3185](https://github.com/jupyterhub/jupyterhub/pull/3185) ([@rainwoodman](https://github.com/rainwoodman)) +* Mention the PAM pitfall on fedora. [#3184](https://github.com/jupyterhub/jupyterhub/pull/3184) ([@rainwoodman](https://github.com/rainwoodman)) +* Added extra documentation for endpoint /users/{name}/servers/{server_name}. [#3159](https://github.com/jupyterhub/jupyterhub/pull/3159) ([@synchronizing](https://github.com/synchronizing)) +* docs: please docs linter (move_cert docstring) [#3151](https://github.com/jupyterhub/jupyterhub/pull/3151) ([@consideRatio](https://github.com/consideRatio)) +* Needed NoEsacpe (NE) option for apache [#3143](https://github.com/jupyterhub/jupyterhub/pull/3143) ([@basvandervlies](https://github.com/basvandervlies)) +* Document external service api_tokens better [#3142](https://github.com/jupyterhub/jupyterhub/pull/3142) ([@snickell](https://github.com/snickell)) +* Remove idle culler example [#3114](https://github.com/jupyterhub/jupyterhub/pull/3114) ([@yuvipanda](https://github.com/yuvipanda)) +* docs: unsqueeze logo, remove unused CSS and templates [#3107](https://github.com/jupyterhub/jupyterhub/pull/3107) ([@consideRatio](https://github.com/consideRatio)) +* Update version in docs/rest-api.yaml [#3104](https://github.com/jupyterhub/jupyterhub/pull/3104) ([@cmd-ntrf](https://github.com/cmd-ntrf)) +* Replace zonca/remotespawner with NERSC/sshspawner [#3086](https://github.com/jupyterhub/jupyterhub/pull/3086) ([@manics](https://github.com/manics)) +* Remove already done named servers from roadmap [#3084](https://github.com/jupyterhub/jupyterhub/pull/3084) ([@elgalu](https://github.com/elgalu)) +* proxy settings might cause authentication errors [#3078](https://github.com/jupyterhub/jupyterhub/pull/3078) ([@gatoniel](https://github.com/gatoniel)) +* Add Configuration Reference section to docs [#3077](https://github.com/jupyterhub/jupyterhub/pull/3077) ([@kinow](https://github.com/kinow)) +* document upgrading from api_tokens to services config [#3055](https://github.com/jupyterhub/jupyterhub/pull/3055) ([@minrk](https://github.com/minrk)) +* [Docs] Disable proxy_buffering when using nginx reverse proxy [#3048](https://github.com/jupyterhub/jupyterhub/pull/3048) ([@mhwasil](https://github.com/mhwasil)) +* docs: add proxy_http_version 1.1 [#3046](https://github.com/jupyterhub/jupyterhub/pull/3046) ([@ceocoder](https://github.com/ceocoder)) +* #1018 PAM added in prerequisites [#3040](https://github.com/jupyterhub/jupyterhub/pull/3040) ([@romainx](https://github.com/romainx)) +* Fix use of auxiliary verb on index.rst [#3022](https://github.com/jupyterhub/jupyterhub/pull/3022) ([@joshmeek](https://github.com/joshmeek)) +* Fix docs CI test failure: duplicate object description [#3021](https://github.com/jupyterhub/jupyterhub/pull/3021) ([@rkdarst](https://github.com/rkdarst)) +* Update issue templates [#3001](https://github.com/jupyterhub/jupyterhub/pull/3001) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +* fix wrong name on firewall [#2997](https://github.com/jupyterhub/jupyterhub/pull/2997) ([@thuvh](https://github.com/thuvh)) +* updating docs theme [#2995](https://github.com/jupyterhub/jupyterhub/pull/2995) ([@choldgraf](https://github.com/choldgraf)) +* Update contributor docs [#2972](https://github.com/jupyterhub/jupyterhub/pull/2972) ([@mriedem](https://github.com/mriedem)) +* Server.user_options rest-api documented [#2966](https://github.com/jupyterhub/jupyterhub/pull/2966) ([@mriedem](https://github.com/mriedem)) +* Pin sphinx theme [#2956](https://github.com/jupyterhub/jupyterhub/pull/2956) ([@manics](https://github.com/manics)) +* [doc] Fix couple typos in the documentation [#2951](https://github.com/jupyterhub/jupyterhub/pull/2951) ([@kinow](https://github.com/kinow)) +* Docs: Fixed grammar on landing page [#2950](https://github.com/jupyterhub/jupyterhub/pull/2950) ([@alexdriedger](https://github.com/alexdriedger)) +* add general faq [#2946](https://github.com/jupyterhub/jupyterhub/pull/2946) ([@minrk](https://github.com/minrk)) +* docs: use metachannel for faster environment solve [#2943](https://github.com/jupyterhub/jupyterhub/pull/2943) ([@minrk](https://github.com/minrk)) +* update docs environments [#2942](https://github.com/jupyterhub/jupyterhub/pull/2942) ([@minrk](https://github.com/minrk)) +* [doc] Add more docs about Cookies used for authentication in JupyterHub [#2940](https://github.com/jupyterhub/jupyterhub/pull/2940) ([@kinow](https://github.com/kinow)) +* [doc] Use fixed commit plus line number in github link [#2939](https://github.com/jupyterhub/jupyterhub/pull/2939) ([@kinow](https://github.com/kinow)) +* [doc] Fix link to SSL encryption from troubleshooting page [#2938](https://github.com/jupyterhub/jupyterhub/pull/2938) ([@kinow](https://github.com/kinow)) +* rest api: fix schema for remove parameter in rest api [#2917](https://github.com/jupyterhub/jupyterhub/pull/2917) ([@minrk](https://github.com/minrk)) +* Add troubleshooting topics [#2914](https://github.com/jupyterhub/jupyterhub/pull/2914) ([@jgwerner](https://github.com/jgwerner)) +* Several fixes to the doc [#2904](https://github.com/jupyterhub/jupyterhub/pull/2904) ([@reneluria](https://github.com/reneluria)) +* fix: 'Non-ASCII character '\xc3' [#2901](https://github.com/jupyterhub/jupyterhub/pull/2901) ([@jgwerner](https://github.com/jgwerner)) +* Generate prometheus metrics docs [#2891](https://github.com/jupyterhub/jupyterhub/pull/2891) ([@rajat404](https://github.com/rajat404)) + +#### Contributors to this release +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2020-01-17&to=2020-10-15&type=c)) + +[@1kastner](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3A1kastner+updated%3A2020-01-17..2020-10-15&type=Issues) | [@alexdriedger](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aalexdriedger+updated%3A2020-01-17..2020-10-15&type=Issues) | [@alexweav](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aalexweav+updated%3A2020-01-17..2020-10-15&type=Issues) | [@Analect](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AAnalect+updated%3A2020-01-17..2020-10-15&type=Issues) | [@analytically](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aanalytically+updated%3A2020-01-17..2020-10-15&type=Issues) | [@AngelOnFira](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AAngelOnFira+updated%3A2020-01-17..2020-10-15&type=Issues) | [@barrachri](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abarrachri+updated%3A2020-01-17..2020-10-15&type=Issues) | [@basvandervlies](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abasvandervlies+updated%3A2020-01-17..2020-10-15&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abetatim+updated%3A2020-01-17..2020-10-15&type=Issues) | [@bigbosst](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abigbosst+updated%3A2020-01-17..2020-10-15&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ablink1073+updated%3A2020-01-17..2020-10-15&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ACarreau+updated%3A2020-01-17..2020-10-15&type=Issues) | [@cbjuan](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acbjuan+updated%3A2020-01-17..2020-10-15&type=Issues) | [@ceocoder](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aceocoder+updated%3A2020-01-17..2020-10-15&type=Issues) | [@chancez](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Achancez+updated%3A2020-01-17..2020-10-15&type=Issues) | [@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acholdgraf+updated%3A2020-01-17..2020-10-15&type=Issues) | [@Chrisjw42](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AChrisjw42+updated%3A2020-01-17..2020-10-15&type=Issues) | [@cmd-ntrf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acmd-ntrf+updated%3A2020-01-17..2020-10-15&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2020-01-17..2020-10-15&type=Issues) | [@danlester](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adanlester+updated%3A2020-01-17..2020-10-15&type=Issues) | [@diurnalist](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adiurnalist+updated%3A2020-01-17..2020-10-15&type=Issues) | [@Dmitry1987](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ADmitry1987+updated%3A2020-01-17..2020-10-15&type=Issues) | [@dylex](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adylex+updated%3A2020-01-17..2020-10-15&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aecharles+updated%3A2020-01-17..2020-10-15&type=Issues) | [@elgalu](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aelgalu+updated%3A2020-01-17..2020-10-15&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2020-01-17..2020-10-15&type=Issues) | [@gatoniel](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agatoniel+updated%3A2020-01-17..2020-10-15&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2020-01-17..2020-10-15&type=Issues) | [@jgwerner](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajgwerner+updated%3A2020-01-17..2020-10-15&type=Issues) | [@JohnPaton](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AJohnPaton+updated%3A2020-01-17..2020-10-15&type=Issues) | [@joshmeek](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajoshmeek+updated%3A2020-01-17..2020-10-15&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajtpio+updated%3A2020-01-17..2020-10-15&type=Issues) | [@kinow](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akinow+updated%3A2020-01-17..2020-10-15&type=Issues) | [@kreuzert](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akreuzert+updated%3A2020-01-17..2020-10-15&type=Issues) | [@kxiao-fn](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akxiao-fn+updated%3A2020-01-17..2020-10-15&type=Issues) | [@lesiano](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Alesiano+updated%3A2020-01-17..2020-10-15&type=Issues) | [@lydian](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Alydian+updated%3A2020-01-17..2020-10-15&type=Issues) | [@mabbasi90](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amabbasi90+updated%3A2020-01-17..2020-10-15&type=Issues) | [@maluhoss](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amaluhoss+updated%3A2020-01-17..2020-10-15&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2020-01-17..2020-10-15&type=Issues) | [@matteoipri](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amatteoipri+updated%3A2020-01-17..2020-10-15&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ameeseeksmachine+updated%3A2020-01-17..2020-10-15&type=Issues) | [@mhwasil](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amhwasil+updated%3A2020-01-17..2020-10-15&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2020-01-17..2020-10-15&type=Issues) | [@mriedem](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amriedem+updated%3A2020-01-17..2020-10-15&type=Issues) | [@nscozzaro](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Anscozzaro+updated%3A2020-01-17..2020-10-15&type=Issues) | [@pabepadu](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apabepadu+updated%3A2020-01-17..2020-10-15&type=Issues) | [@possiblyMikeB](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ApossiblyMikeB+updated%3A2020-01-17..2020-10-15&type=Issues) | [@psyvision](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apsyvision+updated%3A2020-01-17..2020-10-15&type=Issues) | [@rabsr](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arabsr+updated%3A2020-01-17..2020-10-15&type=Issues) | [@rainwoodman](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arainwoodman+updated%3A2020-01-17..2020-10-15&type=Issues) | [@rajat404](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arajat404+updated%3A2020-01-17..2020-10-15&type=Issues) | [@rcthomas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2020-01-17..2020-10-15&type=Issues) | [@reneluria](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Areneluria+updated%3A2020-01-17..2020-10-15&type=Issues) | [@rkdarst](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arkdarst+updated%3A2020-01-17..2020-10-15&type=Issues) | [@rkevin-arch](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arkevin-arch+updated%3A2020-01-17..2020-10-15&type=Issues) | [@romainx](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aromainx+updated%3A2020-01-17..2020-10-15&type=Issues) | [@ryanlovett](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryanlovett+updated%3A2020-01-17..2020-10-15&type=Issues) | [@ryogesh](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryogesh+updated%3A2020-01-17..2020-10-15&type=Issues) | [@sdague](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asdague+updated%3A2020-01-17..2020-10-15&type=Issues) | [@snickell](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asnickell+updated%3A2020-01-17..2020-10-15&type=Issues) | [@SonakshiGrover](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ASonakshiGrover+updated%3A2020-01-17..2020-10-15&type=Issues) | [@steinad](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asteinad+updated%3A2020-01-17..2020-10-15&type=Issues) | [@stephen-a2z](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Astephen-a2z+updated%3A2020-01-17..2020-10-15&type=Issues) | [@stevegore](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Astevegore+updated%3A2020-01-17..2020-10-15&type=Issues) | [@stv0g](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Astv0g+updated%3A2020-01-17..2020-10-15&type=Issues) | [@subgero](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asubgero+updated%3A2020-01-17..2020-10-15&type=Issues) | [@sudi007](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asudi007+updated%3A2020-01-17..2020-10-15&type=Issues) | [@summerswallow](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asummerswallow+updated%3A2020-01-17..2020-10-15&type=Issues) | [@support](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asupport+updated%3A2020-01-17..2020-10-15&type=Issues) | [@synchronizing](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asynchronizing+updated%3A2020-01-17..2020-10-15&type=Issues) | [@thuvh](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Athuvh+updated%3A2020-01-17..2020-10-15&type=Issues) | [@tritemio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atritemio+updated%3A2020-01-17..2020-10-15&type=Issues) | [@twalcari](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atwalcari+updated%3A2020-01-17..2020-10-15&type=Issues) | [@vchandvankar](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Avchandvankar+updated%3A2020-01-17..2020-10-15&type=Issues) | [@vilhelmen](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Avilhelmen+updated%3A2020-01-17..2020-10-15&type=Issues) | [@vlizanae](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Avlizanae+updated%3A2020-01-17..2020-10-15&type=Issues) | [@weimin](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aweimin+updated%3A2020-01-17..2020-10-15&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2020-01-17..2020-10-15&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awillingc+updated%3A2020-01-17..2020-10-15&type=Issues) | [@yhal-nesi](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayhal-nesi+updated%3A2020-01-17..2020-10-15&type=Issues) | [@ynnelson](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aynnelson+updated%3A2020-01-17..2020-10-15&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2020-01-17..2020-10-15&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AZsailer+updated%3A2020-01-17..2020-10-15&type=Issues) ## 1.1 @@ -43,7 +189,6 @@ Thanks to everyone who has contributed to this release! - Add prometheus metric to measure hub startup time [#2799](https://github.com/jupyterhub/jupyterhub/pull/2799) ([@rajat404](https://github.com/rajat404)) - Add Spawner.auth_state_hook [#2555](https://github.com/jupyterhub/jupyterhub/pull/2555) ([@rcthomas](https://github.com/rcthomas)) - Link services from jupyterhub pages [#2763](https://github.com/jupyterhub/jupyterhub/pull/2763) ([@rcthomas](https://github.com/rcthomas)) -- Add Spawner.auth_state_hook [#2555](https://github.com/jupyterhub/jupyterhub/pull/2555) ([@rcthomas](https://github.com/rcthomas)) - `JupyterHub.user_redirect_hook` is added to allow admins to customize /user-redirect/ behavior [#2790](https://github.com/jupyterhub/jupyterhub/pull/2790) ([@yuvipanda](https://github.com/yuvipanda)) - Add prometheus metric to measure hub startup time [#2799](https://github.com/jupyterhub/jupyterhub/pull/2799) ([@rajat404](https://github.com/rajat404)) - Add prometheus metric to measure proxy route poll times [#2798](https://github.com/jupyterhub/jupyterhub/pull/2798) ([@rajat404](https://github.com/rajat404)) diff --git a/jupyterhub/_version.py b/jupyterhub/_version.py index f3376877..3f64fe6f 100644 --- a/jupyterhub/_version.py +++ b/jupyterhub/_version.py @@ -6,8 +6,8 @@ version_info = ( 1, 2, 0, - # "", # release (b1, rc1, or "" for final or dev) - "dev", # dev or nothing for beta/rc/stable releases + "b1", # release (b1, rc1, or "" for final or dev) + # "dev", # dev or nothing for beta/rc/stable releases ) # pep 440 version: no dot before beta/rc, but before .dev diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index d59cfb12..8ebc15d4 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -190,6 +190,7 @@ class APIHandler(BaseHandler): 'kind': 'user', 'name': user.name, 'admin': user.admin, + 'roles': [r.name for r in user.roles], 'groups': [g.name for g in user.groups], 'server': user.url if user.running else None, 'pending': None, diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 44c829ff..8398de6d 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -13,6 +13,7 @@ from tornado import web from tornado.iostream import StreamClosedError from .. import orm +from .. import roles from ..user import User from ..utils import admin_only from ..utils import isoformat @@ -87,7 +88,8 @@ class UserListAPIHandler(APIHandler): user = self.user_from_username(name) if admin: user.admin = True - self.db.commit() + roles.DefaultRoles.add_default_role(self.db, user) + self.db.commit() try: await maybe_future(self.authenticator.add_user(user)) except Exception as e: @@ -149,7 +151,8 @@ class UserAPIHandler(APIHandler): self._check_user_model(data) if 'admin' in data: user.admin = data['admin'] - self.db.commit() + roles.DefaultRoles.add_default_role(self.db, user) + self.db.commit() try: await maybe_future(self.authenticator.add_user(user)) @@ -205,6 +208,8 @@ class UserAPIHandler(APIHandler): if key == 'auth_state': await user.save_auth_state(value) else: + if key == 'admin' and value != user.admin: + roles.DefaultRoles.change_admin(self.db, user=user, admin=value) setattr(user, key, value) self.db.commit() user_ = self.user_model(user) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 9b11fef0..f72f899e 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -73,6 +73,7 @@ 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 ._data import DATA_FILES_PATH @@ -311,6 +312,28 @@ class JupyterHub(Application): """, ).tag(config=True) + load_roles = List( + Dict(), + help="""List of predefined role dictionaries to load at startup. + + For instance:: + + roles = [ + { + 'name': 'teacher', + 'description': 'Access users information, servers and groups without create/delete privileges', + 'scopes': ['users', 'groups'], + 'users': ['cyclops', 'wolverine'] + } + ] + + See all the available scopes in the JupyterHub REST API documentation. + + The default roles are in roles.py. + + """, + ).tag(config=True) + config_file = Unicode('jupyterhub_config.py', help="The config file to load").tag( config=True ) @@ -1692,6 +1715,7 @@ class JupyterHub(Application): for name in admin_users: # ensure anyone specified as admin in config is admin in db + # and gets admin role user = orm.User.find(db, name) if user is None: user = orm.User(name=name, admin=True) @@ -1699,7 +1723,6 @@ class JupyterHub(Application): db.add(user) else: user.admin = True - # the admin_users config variable will never be used after this point. # only the database values will be referenced. @@ -1798,6 +1821,37 @@ class JupyterHub(Application): group.users.append(user) db.commit() + async def init_roles(self): + """Load default and predefined roles into the database""" + db = self.db + # load default roles + roles.DefaultRoles.load_to_database(db) + + # load predefined roles from config file + for predef_role in self.load_roles: + role = roles.add_predef_role(db, predef_role) + # handle users + for username in predef_role['users']: + username = self.authenticator.normalize_username(username) + if not ( + await maybe_future(self.authenticator.check_allowed(username, None)) + ): + raise ValueError( + "Username %r is not in Authenticator.allowed_users" % username + ) + user = orm.User.find(db, name=username) + if user is None: + if not self.authenticator.validate_username(username): + raise ValueError("Role username %r is not valid" % username) + user = orm.User(name=username) + db.add(user) + roles.add_user(db, user=user, role=role) + + # make sure every existing user has a default user or admin role + for user in db.query(orm.User): + roles.DefaultRoles.add_default_role(db, user) + db.commit() + async def _add_tokens(self, token_dict, kind): """Add tokens for users or services to the database""" if kind == 'user': @@ -2376,6 +2430,7 @@ class JupyterHub(Application): self.init_oauth() await self.init_users() await self.init_groups() + await self.init_roles() self.init_services() await self.init_api_tokens() self.init_tornado_settings() diff --git a/jupyterhub/dbutil.py b/jupyterhub/dbutil.py index 703de8f4..e3fc7850 100644 --- a/jupyterhub/dbutil.py +++ b/jupyterhub/dbutil.py @@ -139,7 +139,7 @@ def upgrade_if_needed(db_url, backup=True, log=None): def shell(args=None): - """Start an IPython shell hooked up to the jupyerhub database""" + """Start an IPython shell hooked up to the jupyterhub database""" from .app import JupyterHub hub = JupyterHub() diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index cc96ca88..1a518bf0 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -90,6 +90,26 @@ class JSONDict(TypeDecorator): return value +class JSONList(JSONDict): + """Represents an immutable structure as a json-encoded string (to be used for list type columns). + + Usage:: + + JSONList(JSONDict) + + """ + + def process_bind_param(self, value, dialect): + if isinstance(value, list) and value is not None: + value = json.dumps(value) + return value + + def process_result_value(self, value, dialect): + if value is not None: + value = json.loads(value) + return value + + Base = declarative_base() Base.log = app_log @@ -113,6 +133,41 @@ class Server(Base): return "" % (self.ip, self.port) +# user:role many:many mapping table +user_role_map = Table( + 'user_role_map', + Base.metadata, + Column('user_id', ForeignKey('users.id', ondelete='CASCADE'), primary_key=True), + Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), +) + + +class Role(Base): + """User Roles""" + + __tablename__ = 'roles' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Unicode(255), unique=True) + description = Column(Unicode(1023)) + scopes = Column(JSONList) + users = relationship('User', secondary='user_role_map', backref='roles') + + def __repr__(self): + return "<%s %s (%s) - scopes: %s>" % ( + self.__class__.__name__, + self.name, + self.description, + self.scopes, + ) + + @classmethod + def find(cls, db, name): + """Find a role by name. + Returns None if not found. + """ + return db.query(cls).filter(cls.name == name).first() + + # user:group many:many mapping table user_group_map = Table( 'user_group_map', diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py new file mode 100644 index 00000000..ac5180e2 --- /dev/null +++ b/jupyterhub/roles.py @@ -0,0 +1,127 @@ +"""Roles utils""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +from .orm import Role + + +# define default roles +class DefaultRoles: + + user = Role(name='user', description='Everything the user can do', scopes=['all']) + admin = Role( + name='admin', + description='Admin privileges (currently can do everything)', + scopes=[ + 'all', + 'users', + 'users:tokens', + 'admin:users', + 'admin:users:servers', + 'groups', + 'admin:groups', + 'read:services', + 'proxy', + 'shutdown', + ], + ) + server = Role( + name='server', + description='Post activity only', + scopes=['users:activity!user=username'], + ) + roles = (user, admin, server) + + def __init__(cls, roles=roles): + cls.roles = roles + + @classmethod + def get_user_role(cls, db): + return Role.find(db, name=cls.user.name) + + @classmethod + def get_admin_role(cls, db): + return Role.find(db, name=cls.admin.name) + + @classmethod + def get_server_role(cls, db): + return Role.find(db, name=cls.server.name) + + @classmethod + def load_to_database(cls, db): + for role in cls.roles: + db_role = Role.find(db, name=role.name) + if db_role is None: + new_role = Role( + name=role.name, description=role.description, scopes=role.scopes, + ) + db.add(new_role) + db.commit() + + @classmethod + def add_default_role(cls, db, user): + role = None + if user.admin and cls.admin not in user.roles: + role = cls.get_admin_role(db) + if not user.admin and cls.user not in user.roles: + role = cls.get_user_role(db) + if role is not None: + add_user(db, user, role) + db.commit() + + @classmethod + def change_admin(cls, db, user, admin): + user_role = cls.get_user_role(db) + admin_role = cls.get_admin_role(db) + if admin: + if user_role in user.roles: + remove_user(db, user, user_role) + add_user(db, user, admin_role) + else: + if admin_role in user.roles: + remove_user(db, user, admin_role) + add_user(db, user, user_role) + db.commit() + + +def add_user(db, user, role): + if role is not None and role not in user.roles: + user.roles.append(role) + db.commit() + + +def remove_user(db, user, role): + if role is not None and role in user.roles: + user.roles.remove(role) + db.commit() + + +def add_predef_role(db, predef_role): + """ + Returns either the role to write into db or updated role if already in db + """ + role = Role.find(db, predef_role['name']) + # if a new role, add to db, if existing, rewrite its attributes apart from the name + if role is None: + role = Role( + name=predef_role['name'], + description=predef_role['description'], + scopes=predef_role['scopes'], + ) + db.add(role) + db.commit() + else: + # check if it's not one of the default roles + if not any(d.name == predef_role['name'] for d in DefaultRoles.roles): + # if description and scopes specified, rewrite the old ones + if 'description' in predef_role.keys(): + role.description = predef_role['description'] + if 'scopes' in predef_role.keys(): + role.scopes = predef_role['scopes'] + # FIXME - for now deletes old users and writes new ones + role.users = [] + else: + raise ValueError( + "The role %r is a default role that cannot be overwritten, use a different role name" + % predef_role['name'] + ) + return role diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 47b38f87..d7fddf8b 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -44,6 +44,7 @@ from traitlets import default from traitlets import Dict from .. import orm +from .. import roles from ..app import JupyterHub from ..auth import PAMAuthenticator from ..objects import Server @@ -318,6 +319,8 @@ class MockHub(JupyterHub): self.db.delete(user) for group in self.db.query(orm.Group): self.db.delete(group) + for role in self.db.query(orm.Role): + self.db.delete(role) self.db.commit() @gen.coroutine @@ -333,6 +336,7 @@ class MockHub(JupyterHub): user = self.db.query(orm.User).filter(orm.User.name == 'user').first() if user is None: user = orm.User(name='user') + roles.DefaultRoles.add_default_role(self.db, user=user) self.db.add(user) self.db.commit() @@ -403,7 +407,7 @@ class StubSingleUserSpawner(MockSpawner): - authenticated, so we are testing auth - always available (i.e. in base ServerApp and NotebookApp """ - return "/api/spec.yaml" + return "/api/status" _thread = None diff --git a/jupyterhub/tests/populate_db.py b/jupyterhub/tests/populate_db.py index 2b5c6007..fd8cb9a1 100644 --- a/jupyterhub/tests/populate_db.py +++ b/jupyterhub/tests/populate_db.py @@ -10,6 +10,9 @@ from datetime import datetime import jupyterhub from jupyterhub import orm +# FIXME - for later versions of jupyterhub add code to test Roles +# from jupyterhub.orm import Role + def populate_db(url): """Populate a jupyterhub database""" diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index fa6a20d1..dab18512 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -17,6 +17,7 @@ from tornado import gen import jupyterhub from .. import orm +from .. import roles from ..utils import url_path_join as ujoin from ..utils import utcnow from .mocking import public_host @@ -66,9 +67,10 @@ async def test_auth_api(app): async def test_referer_check(app): url = ujoin(public_host(app), app.hub.base_url) host = urlparse(url).netloc + # add admin user user = find_user(app.db, 'admin') if user is None: - user = add_user(app.db, name='admin', admin=True) + user = add_user(app.db, name='admin', admin=True, roles=['admin']) cookies = await app.login_user('admin') r = await api_request( @@ -152,6 +154,7 @@ def fill_user(model): """ model.setdefault('server', None) model.setdefault('kind', 'user') + model.setdefault('roles', []) model.setdefault('groups', []) model.setdefault('admin', False) model.setdefault('server', None) @@ -166,6 +169,7 @@ TIMESTAMP = normalize_timestamp(datetime.now().isoformat() + 'Z') @mark.user +@mark.role async def test_get_users(app): db = app.db r = await api_request(app, 'users') @@ -174,8 +178,10 @@ async def test_get_users(app): users = sorted(r.json(), key=lambda d: d['name']) users = [normalize_user(u) for u in users] assert users == [ - fill_user({'name': 'admin', 'admin': True}), - fill_user({'name': 'user', 'admin': False, 'last_activity': None}), + fill_user({'name': 'admin', 'admin': True, 'roles': ['admin']}), + fill_user( + {'name': 'user', 'admin': False, 'roles': ['user'], 'last_activity': None} + ), ] r = await api_request(app, 'users', headers=auth_header(db, 'user')) @@ -216,6 +222,7 @@ async def test_get_self(app): @mark.user +@mark.role async def test_add_user(app): db = app.db name = 'newuser' @@ -225,16 +232,20 @@ async def test_add_user(app): assert user is not None assert user.name == name assert not user.admin + # assert newuser has default 'user' role + assert roles.DefaultRoles.get_user_role(db=db) in user.roles + assert roles.DefaultRoles.get_admin_role(db=db) not in user.roles @mark.user +@mark.role async def test_get_user(app): name = 'user' r = await api_request(app, 'users', name) assert r.status_code == 200 user = normalize_user(r.json()) - assert user == fill_user({'name': name, 'auth_state': None}) + assert user == fill_user({'name': name, 'roles': ['user'], 'auth_state': None}) @mark.user @@ -262,6 +273,7 @@ async def test_add_multi_user_invalid(app): @mark.user +@mark.role async def test_add_multi_user(app): db = app.db names = ['a', 'b'] @@ -278,6 +290,9 @@ async def test_add_multi_user(app): assert user is not None assert user.name == name assert not user.admin + # assert default 'user' role added + assert roles.DefaultRoles.get_user_role(db=db) in user.roles + assert roles.DefaultRoles.get_admin_role(db=db) not in user.roles # try to create the same users again r = await api_request( @@ -298,6 +313,7 @@ async def test_add_multi_user(app): @mark.user +@mark.role async def test_add_multi_user_admin(app): db = app.db names = ['c', 'd'] @@ -317,6 +333,8 @@ async def test_add_multi_user_admin(app): assert user is not None assert user.name == name assert user.admin + assert roles.DefaultRoles.get_user_role(db=db) not in user.roles + assert roles.DefaultRoles.get_admin_role(db=db) in user.roles @mark.user @@ -342,6 +360,7 @@ async def test_add_user_duplicate(app): @mark.user +@mark.role async def test_add_admin(app): db = app.db name = 'newadmin' @@ -350,9 +369,13 @@ async def test_add_admin(app): ) assert r.status_code == 201 user = find_user(db, name) + user_role = orm.Role.find(db, 'user') assert user is not None assert user.name == name assert user.admin + # assert newadmin has default 'admin' role + assert roles.DefaultRoles.get_user_role(db=db) not in user.roles + assert roles.DefaultRoles.get_admin_role(db=db) in user.roles @mark.user @@ -364,6 +387,7 @@ async def test_delete_user(app): @mark.user +@mark.role async def test_make_admin(app): db = app.db name = 'admin2' @@ -373,15 +397,20 @@ async def test_make_admin(app): assert user is not None assert user.name == name assert not user.admin + assert roles.DefaultRoles.get_user_role(db=db) in user.roles + assert roles.DefaultRoles.get_admin_role(db=db) not in user.roles r = await api_request( app, 'users', name, method='patch', data=json.dumps({'admin': True}) ) + assert r.status_code == 200 user = find_user(db, name) assert user is not None assert user.name == name assert user.admin + assert roles.DefaultRoles.get_user_role(db=db) not in user.roles + assert roles.DefaultRoles.get_admin_role(db=db) in user.roles @mark.user diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index 0c125c5a..4b6c12ae 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -245,10 +245,12 @@ def test_groups(db): db.commit() assert group.users == [] assert user.groups == [] + group.users.append(user) db.commit() assert group.users == [user] assert user.groups == [group] + db.delete(user) db.commit() assert group.users == [] @@ -460,7 +462,7 @@ def test_group_delete_cascade(db): assert group2 in user2.groups # now start deleting - # 1. remove group via user.groups + # 1. remove group via user.group user1.groups.remove(group2) db.commit() assert user1 not in group2.users @@ -480,6 +482,7 @@ def test_group_delete_cascade(db): # 4. delete user object db.delete(user1) + db.delete(user2) db.commit() assert user1 not in group1.users @@ -557,3 +560,4 @@ def test_expiring_oauth_code(app, user): assert orm_code in db.query(orm.OAuthCode) orm.OAuthCode.purge_expired(db) assert orm_code not in db.query(orm.OAuthCode) + diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py new file mode 100644 index 00000000..5c3c0689 --- /dev/null +++ b/jupyterhub/tests/test_roles.py @@ -0,0 +1,135 @@ +"""Test roles""" +# import pytest +from pytest import mark + +from .. import orm +from ..roles import DefaultRoles +from .mocking import MockHub + + +@mark.role +def test_roles(db): + """Test orm roles setup""" + user = orm.User(name='falafel') + db.add(user) + role = orm.Role(name='default') + db.add(role) + db.commit() + assert role.users == [] + assert user.roles == [] + + role.users.append(user) + db.commit() + assert role.users == [user] + assert user.roles == [role] + + db.delete(user) + db.commit() + assert role.users == [] + db.delete(role) + + +@mark.role +def test_role_delete_cascade(db): + """Orm roles cascade""" + user1 = orm.User(name='user1') + user2 = orm.User(name='user2') + role1 = orm.Role(name='role1') + role2 = orm.Role(name='role2') + db.add(user1) + db.add(user2) + db.add(role1) + db.add(role2) + db.commit() + # add user to role via user.roles + user1.roles.append(role1) + db.commit() + assert user1 in role1.users + assert role1 in user1.roles + + # add user to role via roles.users + role1.users.append(user2) + db.commit() + assert user2 in role1.users + assert role1 in user2.roles + + # fill role2 and check role1 again + role2.users.append(user1) + role2.users.append(user2) + db.commit() + assert user1 in role1.users + assert user2 in role1.users + assert user1 in role2.users + assert user2 in role2.users + assert role1 in user1.roles + assert role1 in user2.roles + assert role2 in user1.roles + assert role2 in user2.roles + + # now start deleting + # 1. remove role via user.roles + user1.roles.remove(role2) + db.commit() + assert user1 not in role2.users + assert role2 not in user1.roles + + # 2. remove user via role.users + role1.users.remove(user2) + db.commit() + assert user2 not in role1.users + assert role1 not in user2.roles + + # 3. delete role object + db.delete(role2) + db.commit() + assert role2 not in user1.roles + assert role2 not in user2.roles + + # 4. delete user object + db.delete(user1) + db.delete(user2) + db.commit() + assert user1 not in role1.users + + +@mark.role +async def test_load_roles(tmpdir, request): + """Test loading default and predefined roles in app.py""" + to_load = [ + { + 'name': 'teacher', + 'description': 'Access users information, servers and groups without create/delete privileges', + 'scopes': ['users', 'groups'], + 'users': ['cyclops', 'gandalf'], + } + ] + kwargs = {'load_roles': to_load} + ssl_enabled = getattr(request.module, "ssl_enabled", False) + if ssl_enabled: + kwargs['internal_certs_location'] = str(tmpdir) + # keep the users and groups from test_load_groups + hub = MockHub(test_clean_db=False, **kwargs) + hub.init_db() + await hub.init_users() + await hub.init_roles() + db = hub.db + # test default roles loaded to database + assert DefaultRoles.get_user_role(db) is not None + assert DefaultRoles.get_admin_role(db) is not None + assert DefaultRoles.get_server_role(db) is not None + # test if every existing user has a correct default role + for user in db.query(orm.User): + assert len(user.roles) == len(set(user.roles)) + if user.admin: + assert DefaultRoles.get_admin_role(db) in user.roles + assert DefaultRoles.get_user_role(db) not in user.roles + else: + assert DefaultRoles.get_user_role(db) in user.roles + assert DefaultRoles.get_admin_role(db) not in user.roles + # test if predefined roles loaded and assigned + teacher_role = orm.Role.find(db, name='teacher') + assert teacher_role is not None + gandalf_user = orm.User.find(db, name='gandalf') + assert teacher_role in gandalf_user.roles + cyclops_user = orm.User.find(db, name='cyclops') + assert teacher_role in cyclops_user.roles diff --git a/jupyterhub/traitlets.py b/jupyterhub/traitlets.py index af3cd7fa..0cc616a9 100644 --- a/jupyterhub/traitlets.py +++ b/jupyterhub/traitlets.py @@ -9,6 +9,7 @@ from traitlets import List from traitlets import TraitError from traitlets import TraitType from traitlets import Type +from traitlets import Undefined from traitlets import Unicode @@ -27,11 +28,15 @@ class Command(List): but allows it to be specified as a single string. """ - def __init__(self, default_value=None, **kwargs): + def __init__(self, default_value=Undefined, **kwargs): kwargs.setdefault('minlen', 1) if isinstance(default_value, str): default_value = [default_value] - super().__init__(Unicode(), default_value, **kwargs) + if default_value is not Undefined and ( + not (default_value is None and not kwargs.get("allow_none", False)) + ): + kwargs["default_value"] = default_value + super().__init__(Unicode(), **kwargs) def validate(self, obj, value): if isinstance(value, str): diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index e3f0a0e6..73235914 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -173,7 +173,8 @@ async def exponential_backoff( # this prevents overloading any single tornado loop iteration with # too many things dt = min(max_wait, remaining, random.uniform(0, start_wait * scale)) - scale *= scale_factor + if dt < max_wait: + scale *= scale_factor await gen.sleep(dt) raise TimeoutError(fail_message) diff --git a/pytest.ini b/pytest.ini index dff95321..4b499de6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -13,3 +13,4 @@ markers = services: mark as a services test user: mark as a test for a user slow: mark a test as slow + role: mark as a test for roles