From ff38a9e383619cc1f8e44a58904d351b033637b2 Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Mon, 7 Sep 2020 16:44:18 +0200 Subject: [PATCH 01/10] scope schema definitions for rest-api --- docs/rest-api.yml | 141 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/docs/rest-api.yml b/docs/rest-api.yml index 09d0ed99..028c895e 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -13,8 +13,40 @@ securityDefinitions: type: apiKey name: Authorization in: header -security: + 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' + 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: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:servers: Read-only access to users' servers + users:activity: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: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 + 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)? - token: [] + - oauth2: + - all basePath: /hub/api produces: - application/json @@ -79,6 +111,10 @@ paths: /users: get: summary: List users + security: + - oauth2: + - users + - read:users responses: '200': description: The Hub's user list @@ -88,6 +124,9 @@ paths: $ref: '#/definitions/User' post: summary: Create multiple users + security: + - oauth2: + - admin:users parameters: - name: body in: body @@ -114,6 +153,11 @@ paths: /users/{name}: get: summary: Get a user by name + security: + - oauth2: + - users + - read:users + - read:users:username parameters: - name: name description: username @@ -127,6 +171,9 @@ paths: $ref: '#/definitions/User' post: summary: Create a single user + security: + - oauth2: + - admin:users parameters: - name: name description: username @@ -141,6 +188,9 @@ paths: patch: summary: Modify a user description: Change a user's name or admin status + security: + - oauth2: + - users parameters: - name: name description: username @@ -167,6 +217,9 @@ paths: $ref: '#/definitions/User' delete: summary: Delete a user + security: + - oauth2: + - admin:users parameters: - name: name description: username @@ -184,6 +237,10 @@ paths: Notify the Hub of activity by the user, e.g. accessing a service or (more likely) actively using a server. + security: + - oauth2: + - users + - users:activity:username parameters: - name: name description: username @@ -236,6 +293,10 @@ paths: /users/{name}/server: post: summary: Start a user's single-user notebook server + security: + - oauth2: + - users + - users:servers parameters: - name: name description: username @@ -262,6 +323,10 @@ paths: description: The user's notebook server has not yet started, but has been requested delete: summary: Stop a user's server + security: + - oauth2: + - users + - users:servers parameters: - name: name description: username @@ -276,6 +341,11 @@ paths: /users/{name}/servers/{server_name}: post: summary: Start a user's single-user named-server notebook server + security: + - oauth2: + - users + - users:servers + - users:servers:servername parameters: - name: name description: username @@ -307,6 +377,11 @@ paths: description: The user's notebook named-server has not yet started, but has been requested delete: summary: Stop a user's named-server + security: + - oauth2: + - users + - users:servers + - users:servers:servername parameters: - name: name description: username @@ -344,6 +419,9 @@ paths: type: string get: summary: List tokens for the user + security: + - oauth2: + - users:tokens responses: '200': description: The list of tokens @@ -357,6 +435,9 @@ paths: description: No such user post: summary: Create a new token for the user + security: + - oauth2: + - users:tokens parameters: - name: token_params in: body @@ -390,6 +471,9 @@ paths: type: string get: summary: Get the model for a token by id + security: + - oauth2: + - users:tokens responses: '200': description: The info for the new token @@ -397,12 +481,19 @@ paths: $ref: '#/definitions/Token' delete: summary: Delete (revoke) a token by id + security: + - oauth2: + - users:tokens responses: '204': description: The token has been deleted /user: get: summary: Return authenticated user's model + security: + - oauth2: + - all + - read:all responses: '200': description: The authenticated user's model is returned. @@ -411,6 +502,10 @@ paths: /groups: get: summary: List groups + security: + - oauth2: + - groups + - read:groups responses: '200': description: The list of groups @@ -421,6 +516,11 @@ paths: /groups/{name}: get: summary: Get a group by name + security: + - oauth2: + - groups + - groups:groupname + - read:groups parameters: - name: name description: group name @@ -434,6 +534,9 @@ paths: $ref: '#/definitions/Group' post: summary: Create a group + security: + - oauth2: + - admin:groups parameters: - name: name description: group name @@ -447,6 +550,9 @@ paths: $ref: '#/definitions/Group' delete: summary: Delete a group + security: + - oauth2: + - admin:groups parameters: - name: name description: group name @@ -459,6 +565,10 @@ paths: /groups/{name}/users: post: summary: Add users to a group + security: + - oauth2: + - groups + - groups:groupname parameters: - name: name description: group name @@ -484,6 +594,10 @@ paths: $ref: '#/definitions/Group' delete: summary: Remove users from a group + security: + - oauth2: + - groups + - groups:groupname parameters: - name: name description: group name @@ -508,6 +622,9 @@ paths: /services: get: summary: List services + security: + - oauth2: + - read:services responses: '200': description: The service list @@ -518,6 +635,9 @@ paths: /services/{name}: get: summary: Get a service by name + security: + - oauth2: + - read:services parameters: - name: name description: service name @@ -533,6 +653,9 @@ paths: get: summary: Get the proxy's routing table description: A convenience alias for getting the routing table directly from the proxy + security: + - oauth2: + - proxy responses: '200': description: Routing table @@ -541,12 +664,18 @@ paths: description: configurable-http-proxy routing table (see configurable-http-proxy docs for details) post: summary: Force the Hub to sync with the proxy + security: + - oauth2: + - proxy responses: '200': description: Success patch: summary: Notify the Hub about a new proxy description: Notifies the Hub of a new proxy to use. + security: + - oauth2: + - proxy parameters: - name: body in: body @@ -579,6 +708,9 @@ paths: in the JSON request body. Logging in via this method is only available when the active Authenticator accepts passwords (e.g. not OAuth). + security: + - oauth2: + - users:tokens # minrk: this is a deprecated alias to POST /users/{name}/tokens, either remove it or use the same scope parameters: - name: credentials in: body @@ -603,6 +735,9 @@ paths: /authorizations/token/{token}: get: summary: Identify a user or service from an API token + security: + - oauth2: + - read:users:tokens # minrk: is it really necessary to have a scope for this, or use self handler for token whoami? parameters: - name: token in: path @@ -633,6 +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 /oauth2/authorize: get: summary: 'OAuth 2.0 authorize endpoint' @@ -714,6 +850,9 @@ paths: /shutdown: post: summary: Shutdown the Hub + security: + - oauth2: + - shutdown parameters: - name: body in: body From f1ed74bae10c17a69772e6289ab163c654ee62f4 Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Thu, 24 Sep 2020 14:05:15 +0200 Subject: [PATCH 02/10] creating roles module --- .circleci/config.yml | 46 ---------- docs/rest-api.yml | 37 ++++---- docs/source/changelog.md | 147 +++++++++++++++++++++++++++++++- jupyterhub/_version.py | 4 +- jupyterhub/apihandlers/base.py | 1 + jupyterhub/apihandlers/users.py | 9 +- jupyterhub/app.py | 57 ++++++++++++- jupyterhub/dbutil.py | 2 +- jupyterhub/orm.py | 55 ++++++++++++ jupyterhub/roles.py | 127 +++++++++++++++++++++++++++ jupyterhub/tests/mocking.py | 6 +- jupyterhub/tests/populate_db.py | 3 + jupyterhub/tests/test_api.py | 37 +++++++- jupyterhub/tests/test_orm.py | 6 +- jupyterhub/tests/test_roles.py | 135 +++++++++++++++++++++++++++++ jupyterhub/traitlets.py | 9 +- jupyterhub/utils.py | 3 +- pytest.ini | 1 + 18 files changed, 607 insertions(+), 78 deletions(-) create mode 100644 jupyterhub/roles.py create mode 100644 jupyterhub/tests/test_roles.py 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 From ced80f9e6bb1edfc0e9de9d104d25c3020c2b93b Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Tue, 20 Oct 2020 08:11:42 +0200 Subject: [PATCH 03/10] removing rest-api.yml changes --- docs/rest-api.yml | 146 +--------------------------------------------- 1 file changed, 1 insertion(+), 145 deletions(-) diff --git a/docs/rest-api.yml b/docs/rest-api.yml index beb5e181..09d0ed99 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -13,40 +13,8 @@ securityDefinitions: type: apiKey name: Authorization in: header - oauth2: - type: oauth2 - flow: accessCode - authorizationUrl: '/hub/api/oauth2/authorize' # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations? - tokenUrl: '/hub/api/oauth2/token' - scopes: - 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!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!group=groupname: Read-only access to specific group's users' activity - read:users:servers: Read-only access to users' servers - users:activity!user=username: Update a user's activity - users:servers: Grants access to start/stop any 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!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 only the apiKey (token: []), change to only oauth2 (with scope all) or have both (either can be used)? +security: - token: [] - - oauth2: - - all basePath: /hub/api produces: - application/json @@ -111,10 +79,6 @@ paths: /users: get: summary: List users - security: - - oauth2: - - users - - read:users responses: '200': description: The Hub's user list @@ -124,9 +88,6 @@ paths: $ref: '#/definitions/User' post: summary: Create multiple users - security: - - oauth2: - - admin:users parameters: - name: body in: body @@ -153,11 +114,6 @@ paths: /users/{name}: get: summary: Get a user by name - security: - - oauth2: - - users - - read:users - - read:users!user=username parameters: - name: name description: username @@ -171,9 +127,6 @@ paths: $ref: '#/definitions/User' post: summary: Create a single user - security: - - oauth2: - - admin:users parameters: - name: name description: username @@ -188,9 +141,6 @@ paths: patch: summary: Modify a user description: Change a user's name or admin status - security: - - oauth2: - - users parameters: - name: name description: username @@ -217,9 +167,6 @@ paths: $ref: '#/definitions/User' delete: summary: Delete a user - security: - - oauth2: - - admin:users parameters: - name: name description: username @@ -237,10 +184,6 @@ paths: Notify the Hub of activity by the user, e.g. accessing a service or (more likely) actively using a server. - security: - - oauth2: - - users - - users:activity!user=username parameters: - name: name description: username @@ -293,10 +236,6 @@ paths: /users/{name}/server: post: summary: Start a user's single-user notebook server - security: - - oauth2: - - users - - users:servers parameters: - name: name description: username @@ -323,10 +262,6 @@ paths: description: The user's notebook server has not yet started, but has been requested delete: summary: Stop a user's server - security: - - oauth2: - - users - - users:servers parameters: - name: name description: username @@ -341,11 +276,6 @@ paths: /users/{name}/servers/{server_name}: post: summary: Start a user's single-user named-server notebook server - security: - - oauth2: - - users - - users:servers - - users:servers!server=servername parameters: - name: name description: username @@ -377,11 +307,6 @@ paths: description: The user's notebook named-server has not yet started, but has been requested delete: summary: Stop a user's named-server - security: - - oauth2: - - users - - users:servers - - users:servers!server=servername parameters: - name: name description: username @@ -419,9 +344,6 @@ paths: type: string get: summary: List tokens for the user - security: - - oauth2: - - users:tokens responses: '200': description: The list of tokens @@ -435,9 +357,6 @@ paths: description: No such user post: summary: Create a new token for the user - security: - - oauth2: - - users:tokens parameters: - name: token_params in: body @@ -471,9 +390,6 @@ paths: type: string get: summary: Get the model for a token by id - security: - - oauth2: - - users:tokens responses: '200': description: The info for the new token @@ -481,19 +397,12 @@ paths: $ref: '#/definitions/Token' delete: summary: Delete (revoke) a token by id - security: - - oauth2: - - users:tokens responses: '204': description: The token has been deleted /user: get: summary: Return authenticated user's model - security: - - oauth2: - - all - - read:all responses: '200': description: The authenticated user's model is returned. @@ -502,10 +411,6 @@ paths: /groups: get: summary: List groups - security: - - oauth2: - - groups - - read:groups responses: '200': description: The list of groups @@ -516,11 +421,6 @@ paths: /groups/{name}: get: summary: Get a group by name - security: - - oauth2: - - groups - - groups!group=groupname - - read:groups parameters: - name: name description: group name @@ -534,9 +434,6 @@ paths: $ref: '#/definitions/Group' post: summary: Create a group - security: - - oauth2: - - admin:groups parameters: - name: name description: group name @@ -550,9 +447,6 @@ paths: $ref: '#/definitions/Group' delete: summary: Delete a group - security: - - oauth2: - - admin:groups parameters: - name: name description: group name @@ -565,10 +459,6 @@ paths: /groups/{name}/users: post: summary: Add users to a group - security: - - oauth2: - - groups - - groups!group=groupname parameters: - name: name description: group name @@ -594,10 +484,6 @@ paths: $ref: '#/definitions/Group' delete: summary: Remove users from a group - security: - - oauth2: - - groups - - groups!group=groupname parameters: - name: name description: group name @@ -622,9 +508,6 @@ paths: /services: get: summary: List services - security: - - oauth2: - - read:services responses: '200': description: The service list @@ -635,9 +518,6 @@ paths: /services/{name}: get: summary: Get a service by name - security: - - oauth2: - - read:services parameters: - name: name description: service name @@ -653,9 +533,6 @@ paths: get: summary: Get the proxy's routing table description: A convenience alias for getting the routing table directly from the proxy - security: - - oauth2: - - proxy responses: '200': description: Routing table @@ -664,18 +541,12 @@ paths: description: configurable-http-proxy routing table (see configurable-http-proxy docs for details) post: summary: Force the Hub to sync with the proxy - security: - - oauth2: - - proxy responses: '200': description: Success patch: summary: Notify the Hub about a new proxy description: Notifies the Hub of a new proxy to use. - security: - - oauth2: - - proxy parameters: - name: body in: body @@ -708,9 +579,6 @@ paths: in the JSON request body. Logging in via this method is only available when the active Authenticator accepts passwords (e.g. not OAuth). - security: - - oauth2: - - users:tokens # minrk: this is a deprecated alias to POST /users/{name}/tokens, either remove it or use the same scope parameters: - name: credentials in: body @@ -735,9 +603,6 @@ paths: /authorizations/token/{token}: get: summary: Identify a user or service from an API token - security: - - oauth2: - - read:users:tokens # minrk: is it really necessary to have a scope for this, or use self handler for token whoami? parameters: - name: token in: path @@ -768,7 +633,6 @@ 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 /oauth2/authorize: get: summary: 'OAuth 2.0 authorize endpoint' @@ -850,9 +714,6 @@ paths: /shutdown: post: summary: Shutdown the Hub - security: - - oauth2: - - shutdown parameters: - name: body in: body @@ -886,11 +747,6 @@ definitions: admin: type: boolean description: Whether the user is an admin - roles: - type: array - description: The names of roles this user has - items: - type: string groups: type: array description: The names of groups where this user is a member From 4142dc1bc08a72dfcca3b5571b8c6eadfd3282f7 Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Wed, 21 Oct 2020 16:36:50 +0200 Subject: [PATCH 04/10] update to roles utils --- jupyterhub/apihandlers/users.py | 8 +- jupyterhub/app.py | 15 ++- jupyterhub/roles.py | 161 ++++++++++++-------------------- jupyterhub/tests/mocking.py | 3 +- jupyterhub/tests/test_api.py | 25 +++-- jupyterhub/tests/test_roles.py | 40 ++++---- 6 files changed, 112 insertions(+), 140 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 8398de6d..c253e4ea 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -88,7 +88,7 @@ class UserListAPIHandler(APIHandler): user = self.user_from_username(name) if admin: user.admin = True - roles.DefaultRoles.add_default_role(self.db, user) + roles.update_roles(self.db, user) self.db.commit() try: await maybe_future(self.authenticator.add_user(user)) @@ -151,7 +151,7 @@ class UserAPIHandler(APIHandler): self._check_user_model(data) if 'admin' in data: user.admin = data['admin'] - roles.DefaultRoles.add_default_role(self.db, user) + roles.update_roles(self.db, user) self.db.commit() try: @@ -208,9 +208,9 @@ 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) + if key == 'admin': + roles.update_roles(self.db, user=user) self.db.commit() user_ = self.user_model(user) user_['auth_state'] = await user.get_auth_state() diff --git a/jupyterhub/app.py b/jupyterhub/app.py index f72f899e..70483993 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1715,7 +1715,6 @@ 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) @@ -1825,11 +1824,15 @@ class JupyterHub(Application): """Load default and predefined roles into the database""" db = self.db # load default roles - roles.DefaultRoles.load_to_database(db) + default_roles = roles.get_default_roles() + for role in default_roles: + roles.add_role(db, role) # load predefined roles from config file for predef_role in self.load_roles: - role = roles.add_predef_role(db, predef_role) + roles.add_role(db, predef_role) + role = orm.Role.find(db, predef_role['name']) + # handle users for username in predef_role['users']: username = self.authenticator.normalize_username(username) @@ -1847,9 +1850,11 @@ class JupyterHub(Application): db.add(user) roles.add_user(db, user=user, role=role) - # make sure every existing user has a default user or admin role + # make sure all users have at least one role (update with default) for user in db.query(orm.User): - roles.DefaultRoles.add_default_role(db, user) + if len(user.roles) < 1: + roles.update_roles(db, user) + db.commit() async def _add_tokens(self, token_dict, kind): diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index ac5180e2..53e0c637 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -4,83 +4,58 @@ from .orm import Role -# define default roles -class DefaultRoles: +def get_default_roles(): - 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) + """Returns a list of default roles dictionaries""" - def __init__(cls, roles=roles): - cls.roles = roles + default_roles = [ + { + 'name': 'user', + 'description': 'Everything the user can do', + 'scopes': ['all'], + }, + { + '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', + ], + }, + { + 'name': 'server', + 'description': 'Post activity only', + 'scopes': ['users:activity!user=username'], + }, + ] + return default_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) +def add_role(db, role_dict): - @classmethod - def get_server_role(cls, db): - return Role.find(db, name=cls.server.name) + """Adds a new role to database or modifies an existing one""" - @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() + role = Role.find(db, role_dict['name']) - @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() + if role is None: + role = Role( + name=role_dict['name'], + description=role_dict['description'], + scopes=role_dict['scopes'], + ) + db.add(role) + else: + role.description = role_dict['description'] + role.scopes = role_dict['scopes'] + db.commit() def add_user(db, user, role): @@ -95,33 +70,21 @@ def remove_user(db, user, 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() +def update_roles(db, user): + + """Updates roles if user has no role with default or when user admin status is changed""" + + user_role = Role.find(db, 'user') + admin_role = Role.find(db, 'admin') + + if user.admin: + if user_role in user.roles: + remove_user(db, user, user_role) + add_user(db, user, admin_role) 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 + if admin_role in user.roles: + remove_user(db, user, admin_role) + # only add user role if the user has no other roles + if len(user.roles) < 1: + add_user(db, user, user_role) + db.commit() diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index d7fddf8b..003e193c 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -336,7 +336,8 @@ 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) + user_role = orm.Role.find(self.db, 'user') + roles.add_user(self.db, user=user, role=user_role) self.db.add(user) self.db.commit() diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index dab18512..f46c22c6 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -233,8 +233,8 @@ async def test_add_user(app): 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 + assert orm.Role.find(db, 'user') in user.roles + assert orm.Role.find(db, 'admin') not in user.roles @mark.user @@ -291,8 +291,8 @@ async def test_add_multi_user(app): 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 + assert orm.Role.find(db, 'user') in user.roles + assert orm.Role.find(db, 'admin') not in user.roles # try to create the same users again r = await api_request( @@ -333,8 +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 + assert orm.Role.find(db, 'user') not in user.roles + assert orm.Role.find(db, 'admin') in user.roles @mark.user @@ -369,13 +369,12 @@ 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 + assert orm.Role.find(db, 'user') not in user.roles + assert orm.Role.find(db, 'admin') in user.roles @mark.user @@ -397,8 +396,8 @@ 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 + assert orm.Role.find(db, 'user') in user.roles + assert orm.Role.find(db, 'admin') not in user.roles r = await api_request( app, 'users', name, method='patch', data=json.dumps({'admin': True}) @@ -409,8 +408,8 @@ async def test_make_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 + assert orm.Role.find(db, 'user') not in user.roles + assert orm.Role.find(db, 'admin') in user.roles @mark.user diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index 5c3c0689..8b20fcf3 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -3,7 +3,7 @@ from pytest import mark from .. import orm -from ..roles import DefaultRoles +from .. import roles from .mocking import MockHub @@ -95,37 +95,41 @@ def test_role_delete_cascade(db): @mark.role async def test_load_roles(tmpdir, request): """Test loading default and predefined roles in app.py""" - to_load = [ + roles_to_load = [ { 'name': 'teacher', 'description': 'Access users information, servers and groups without create/delete privileges', 'scopes': ['users', 'groups'], 'users': ['cyclops', 'gandalf'], - } + }, + { + 'name': 'user', + 'description': 'Only read access', + 'scopes': ['read:all'], + 'users': ['test_user'], + }, ] - kwargs = {'load_roles': to_load} + kwargs = {'load_roles': 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 = MockHub(**kwargs) hub.init_db() + db = hub.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 + # test if the 'user' role has been overwritten + user_role = orm.Role.find(db, 'user') + assert user_role is not None + assert user_role.scopes == ['read:all'] + # test other default roles loaded to database + assert orm.Role.find(db, 'user') is not None + assert orm.Role.find(db, 'admin') is not None + assert orm.Role.find(db, 'server') is not None + # test if every existing user has a role (and no duplicates) for user in db.query(orm.User): + assert len(user.roles) > 0 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 From 087c763d410afa09ccf0834db6695bbf51e2271e Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Wed, 28 Oct 2020 11:16:03 +0100 Subject: [PATCH 05/10] adding roles to services --- jupyterhub/apihandlers/base.py | 7 +- jupyterhub/apihandlers/services.py | 1 + jupyterhub/app.py | 54 +++++++----- jupyterhub/orm.py | 11 +++ jupyterhub/roles.py | 2 + jupyterhub/services/service.py | 1 + jupyterhub/tests/populate_db.py | 3 +- jupyterhub/tests/test_api.py | 2 + jupyterhub/tests/test_roles.py | 109 ++++++++++++++++++++++--- jupyterhub/tests/test_services_auth.py | 4 +- 10 files changed, 158 insertions(+), 36 deletions(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 8ebc15d4..8bd05ecc 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -222,7 +222,12 @@ class APIHandler(BaseHandler): def service_model(self, service): """Get the JSON model for a Service object""" - return {'kind': 'service', 'name': service.name, 'admin': service.admin} + return { + 'kind': 'service', + 'name': service.name, + 'admin': service.admin, + 'roles': [r.name for r in service.roles], + } _user_model_types = {'name': str, 'admin': bool, 'groups': list, 'auth_state': dict} diff --git a/jupyterhub/apihandlers/services.py b/jupyterhub/apihandlers/services.py index 3d1f7bcc..10ee1fec 100644 --- a/jupyterhub/apihandlers/services.py +++ b/jupyterhub/apihandlers/services.py @@ -18,6 +18,7 @@ def service_model(service): return { 'name': service.name, 'admin': service.admin, + 'roles': [r.name for r in service.roles], 'url': service.url, 'prefix': service.server.base_url if service.server else '', 'command': service.command, diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 70483993..6618c6f0 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1834,26 +1834,39 @@ class JupyterHub(Application): role = orm.Role.find(db, predef_role['name']) # 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) + if 'users' in predef_role.keys(): + 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: + raise ValueError("%r does not exist" % username) + else: + roles.add_user(db, user=user, role=role) - # make sure all users have at least one role (update with default) - for user in db.query(orm.User): - if len(user.roles) < 1: - roles.update_roles(db, user) + # handle services + if 'services' in predef_role.keys(): + for servicename in predef_role['services']: + service = orm.Service.find(db, name=servicename) + if service is None: + raise ValueError("%r does not exist" % servicename) + else: + roles.add_user(db, user=service, role=role) + + # make sure all users and services have at least one role (update with default) + Classes = [orm.User, orm.Service] + for ormClass in Classes: + for obj in db.query(ormClass): + if len(obj.roles) < 1: + roles.update_roles(db, obj) db.commit() @@ -1968,6 +1981,7 @@ class JupyterHub(Application): base_url=self.base_url, db=self.db, orm=orm_service, + roles=orm_service.roles, domain=domain, host=host, hub=self.hub, @@ -2435,9 +2449,9 @@ 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() + await self.init_roles() self.init_tornado_settings() self.init_handlers() self.init_tornado_application() diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 1a518bf0..66d9f31d 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -141,6 +141,16 @@ user_role_map = Table( Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), ) +# service:role many:many mapping table +service_role_map = Table( + 'service_role_map', + Base.metadata, + Column( + 'service_id', ForeignKey('services.id', ondelete='CASCADE'), primary_key=True + ), + Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), +) + class Role(Base): """User Roles""" @@ -151,6 +161,7 @@ class Role(Base): description = Column(Unicode(1023)) scopes = Column(JSONList) users = relationship('User', secondary='user_role_map', backref='roles') + services = relationship('Service', secondary='service_role_map', backref='roles') def __repr__(self): return "<%s %s (%s) - scopes: %s>" % ( diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 53e0c637..a56b3344 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -55,6 +55,8 @@ def add_role(db, role_dict): else: role.description = role_dict['description'] role.scopes = role_dict['scopes'] + role.users = [] + role.services = [] db.commit() diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py index 10883cb1..44fd763c 100644 --- a/jupyterhub/services/service.py +++ b/jupyterhub/services/service.py @@ -267,6 +267,7 @@ class Service(LoggingConfigurable): base_url = Unicode() db = Any() orm = Any() + roles = Any() cookie_options = Dict() oauth_provider = Any() diff --git a/jupyterhub/tests/populate_db.py b/jupyterhub/tests/populate_db.py index fd8cb9a1..fec3d7e9 100644 --- a/jupyterhub/tests/populate_db.py +++ b/jupyterhub/tests/populate_db.py @@ -10,8 +10,7 @@ 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 +# FIXME - for later versions of jupyterhub add code to test roles def populate_db(url): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index f46c22c6..966dadcf 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1536,6 +1536,7 @@ async def test_get_services(app, mockservice_url): mockservice.name: { 'name': mockservice.name, 'admin': True, + 'roles': [], 'command': mockservice.command, 'pid': mockservice.proc.pid, 'prefix': mockservice.server.base_url, @@ -1561,6 +1562,7 @@ async def test_get_service(app, mockservice_url): assert service == { 'name': mockservice.name, 'admin': True, + 'roles': [], 'command': mockservice.command, 'pid': mockservice.proc.pid, 'prefix': mockservice.server.base_url, diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index 8b20fcf3..1e4ebe1d 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -4,33 +4,45 @@ from pytest import mark from .. import orm from .. import roles +from ..utils import maybe_future from .mocking import MockHub @mark.role -def test_roles(db): +def test_orm_roles(db): """Test orm roles setup""" user = orm.User(name='falafel') db.add(user) + service = orm.Service(name='kebab') + db.add(service) role = orm.Role(name='default') db.add(role) db.commit() assert role.users == [] + assert role.services == [] assert user.roles == [] + assert service.roles == [] role.users.append(user) + role.services.append(service) db.commit() assert role.users == [user] assert user.roles == [role] + assert role.services == [service] + assert service.roles == [role] db.delete(user) db.commit() assert role.users == [] db.delete(role) + db.commit() + assert service.roles == [] + db.delete(service) + db.commit() @mark.role -def test_role_delete_cascade(db): +def test_orm_role_delete_cascade(db): """Orm roles cascade""" user1 = orm.User(name='user1') user2 = orm.User(name='user2') @@ -93,8 +105,25 @@ def test_role_delete_cascade(db): @mark.role -async def test_load_roles(tmpdir, request): - """Test loading default and predefined roles in app.py""" +async def test_load_default_roles(tmpdir, request): + """Test loading default roles in app.py""" + kwargs = {} + ssl_enabled = getattr(request.module, "ssl_enabled", False) + if ssl_enabled: + kwargs['internal_certs_location'] = str(tmpdir) + hub = MockHub(**kwargs) + hub.init_db() + db = hub.db + await hub.init_roles() + # test default roles loaded to database + assert orm.Role.find(db, 'user') is not None + assert orm.Role.find(db, 'admin') is not None + assert orm.Role.find(db, 'server') is not None + + +@mark.role +async def test_load_roles_users(tmpdir, request): + """Test loading predefined roles for users in app.py""" roles_to_load = [ { 'name': 'teacher', @@ -106,7 +135,7 @@ async def test_load_roles(tmpdir, request): 'name': 'user', 'description': 'Only read access', 'scopes': ['read:all'], - 'users': ['test_user'], + 'users': ['bilbo'], }, ] kwargs = {'load_roles': roles_to_load} @@ -116,20 +145,28 @@ async def test_load_roles(tmpdir, request): hub = MockHub(**kwargs) hub.init_db() db = hub.db + hub.authenticator.admin_users = ['admin'] + hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel'] await hub.init_users() + for user in db.query(orm.User): + print(user.name) await hub.init_roles() - # test if the 'user' role has been overwritten + + # test if the 'user' role has been overwritten and assigned user_role = orm.Role.find(db, 'user') + admin_role = orm.Role.find(db, 'admin') assert user_role is not None assert user_role.scopes == ['read:all'] - # test other default roles loaded to database - assert orm.Role.find(db, 'user') is not None - assert orm.Role.find(db, 'admin') is not None - assert orm.Role.find(db, 'server') is not None - # test if every existing user has a role (and no duplicates) + + # test if every user has a role (and no duplicates) + # and admins have admin role for user in db.query(orm.User): assert len(user.roles) > 0 assert len(user.roles) == len(set(user.roles)) + if user.admin: + assert admin_role in user.roles + assert user_role 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 @@ -137,3 +174,53 @@ async def test_load_roles(tmpdir, request): assert teacher_role in gandalf_user.roles cyclops_user = orm.User.find(db, name='cyclops') assert teacher_role in cyclops_user.roles + + +@mark.role +async def test_load_roles_services(tmpdir, request): + roles_to_load = [ + { + 'name': 'culler', + 'description': 'Cull idle servers', + 'scopes': ['users:servers', 'admin:servers'], + 'services': ['cull_idle'], + }, + ] + kwargs = {'load_roles': roles_to_load} + ssl_enabled = getattr(request.module, "ssl_enabled", False) + if ssl_enabled: + kwargs['internal_certs_location'] = str(tmpdir) + hub = MockHub(test_clean_db=False, **kwargs) + hub.init_db() + db = hub.db + # add test services to db + services = [ + {'name': 'cull_idle', 'admin': False}, + {'name': 'user_service', 'admin': False}, + {'name': 'admin_service', 'admin': True}, + ] + for service_specs in services: + service = orm.Service.find(db, service_specs['name']) + if service is None: + service = orm.Service( + name=service_specs['name'], admin=service_specs['admin'] + ) + db.add(service) + db.commit() + await hub.init_roles() + + # test if every service has a role (and no duplicates) + admin_role = orm.Role.find(db, name='admin') + user_role = orm.Role.find(db, name='user') + for service in db.query(orm.Service): + assert len(service.roles) > 0 + assert len(service.roles) == len(set(service.roles)) + if service.admin: + assert admin_role in service.roles + assert user_role not in service.roles + + # test if predefined roles loaded and assigned + culler_role = orm.Role.find(db, name='culler') + cull_idle = orm.Service.find(db, name='cull_idle') + assert culler_role in cull_idle.roles + assert user_role not in cull_idle.roles diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 867d1d97..a0bf5d43 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -295,7 +295,7 @@ async def test_hubauth_service_token(app, mockservice_url): ) r.raise_for_status() reply = r.json() - assert reply == {'kind': 'service', 'name': name, 'admin': False} + assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []} assert not r.cookies # token in ?token parameter @@ -304,7 +304,7 @@ async def test_hubauth_service_token(app, mockservice_url): ) r.raise_for_status() reply = r.json() - assert reply == {'kind': 'service', 'name': name, 'admin': False} + assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []} r = await async_requests.get( public_url(app, mockservice_url) + '/whoami/?token=no-such-token', From c0cadc384d1a23dc52f4d22aa297946e029d21c6 Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Thu, 19 Nov 2020 08:22:52 +0100 Subject: [PATCH 06/10] adding roles to tokens --- jupyterhub/apihandlers/base.py | 4 + jupyterhub/apihandlers/users.py | 17 ++- jupyterhub/app.py | 79 +++++----- jupyterhub/handlers/base.py | 3 + jupyterhub/orm.py | 25 +++ jupyterhub/roles.py | 159 ++++++++++++++----- jupyterhub/tests/mocking.py | 6 +- jupyterhub/tests/populate_db.py | 2 - jupyterhub/tests/test_api.py | 7 +- jupyterhub/tests/test_named_servers.py | 6 +- jupyterhub/tests/test_orm.py | 1 - jupyterhub/tests/test_roles.py | 201 +++++++++++++++++++++---- jupyterhub/tests/utils.py | 3 + 13 files changed, 382 insertions(+), 131 deletions(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 8bd05ecc..a416e6c8 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -150,10 +150,13 @@ class APIHandler(BaseHandler): expires_at = None if isinstance(token, orm.APIToken): kind = 'api_token' + roles = [r.name for r in token.roles] extra = {'note': token.note} expires_at = token.expires_at elif isinstance(token, orm.OAuthAccessToken): kind = 'oauth' + # oauth tokens do not bear roles + roles = [] extra = {'oauth_client': token.client.description or token.client.client_id} if token.expires_at: expires_at = datetime.fromtimestamp(token.expires_at) @@ -174,6 +177,7 @@ class APIHandler(BaseHandler): owner_key: owner, 'id': token.api_id, 'kind': kind, + 'roles': [role for role in roles], 'created': isoformat(token.created), 'last_activity': isoformat(token.last_activity), 'expires_at': isoformat(expires_at), diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index c253e4ea..2a072190 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -14,6 +14,7 @@ from tornado.iostream import StreamClosedError from .. import orm from .. import roles +from ..roles import update_roles from ..user import User from ..utils import admin_only from ..utils import isoformat @@ -88,7 +89,7 @@ class UserListAPIHandler(APIHandler): user = self.user_from_username(name) if admin: user.admin = True - roles.update_roles(self.db, user) + update_roles(self.db, obj=user, kind='users') self.db.commit() try: await maybe_future(self.authenticator.add_user(user)) @@ -151,7 +152,7 @@ class UserAPIHandler(APIHandler): self._check_user_model(data) if 'admin' in data: user.admin = data['admin'] - roles.update_roles(self.db, user) + update_roles(self.db, obj=user, kind='users') self.db.commit() try: @@ -210,7 +211,7 @@ class UserAPIHandler(APIHandler): else: setattr(user, key, value) if key == 'admin': - roles.update_roles(self.db, user=user) + update_roles(self.db, obj=user, kind='users') self.db.commit() user_ = self.user_model(user) user_['auth_state'] = await user.get_auth_state() @@ -296,9 +297,13 @@ class UserTokenListAPIHandler(APIHandler): if requester is not user: note += " by %s %s" % (kind, requester.name) - api_token = user.new_api_token( - note=note, expires_in=body.get('expires_in', None) - ) + token_roles = body.get('roles') + try: + api_token = user.new_api_token( + note=note, expires_in=body.get('expires_in', None), roles=token_roles + ) + except ValueError: + raise web.HTTPError(404, "Requested roles %r not found" % token_roles) if requester is not user: self.log.info( "%s %s requested API token for %s", diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 6618c6f0..4ea92bc7 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -318,18 +318,21 @@ class JupyterHub(Application): For instance:: - roles = [ - { - 'name': 'teacher', - 'description': 'Access users information, servers and groups without create/delete privileges', - 'scopes': ['users', 'groups'], - 'users': ['cyclops', 'wolverine'] - } - ] + load_roles = [ + { + 'name': 'teacher', + 'description': 'Access users information, servers and groups without create/delete privileges', + 'scopes': ['users', 'groups'], + 'users': ['cyclops', 'gandalf'], + 'services': [], + 'tokens': [] + } + ] + All keys apart from 'name' are optional. See all the available scopes in the JupyterHub REST API documentation. - The default roles are in roles.py. + Default roles are defined in roles.py. """, ).tag(config=True) @@ -1823,6 +1826,8 @@ class JupyterHub(Application): async def init_roles(self): """Load default and predefined roles into the database""" db = self.db + role_bearers = ['users', 'services', 'tokens'] + # load default roles default_roles = roles.get_default_roles() for role in default_roles: @@ -1831,43 +1836,31 @@ class JupyterHub(Application): # load predefined roles from config file for predef_role in self.load_roles: roles.add_role(db, predef_role) - role = orm.Role.find(db, predef_role['name']) - - # handle users - if 'users' in predef_role.keys(): - for username in predef_role['users']: - username = self.authenticator.normalize_username(username) - if not ( - await maybe_future( - self.authenticator.check_allowed(username, None) + # add users, services and/or tokens + for bearer in role_bearers: + if bearer in predef_role.keys(): + for bname in predef_role[bearer]: + if bearer == 'users': + bname = self.authenticator.normalize_username(bname) + if not ( + await maybe_future( + self.authenticator.check_allowed(bname, None) + ) + ): + raise ValueError( + "Username %r is not in Authenticator.allowed_users" + % bname + ) + roles.add_obj( + db, objname=bname, kind=bearer, rolename=predef_role['name'] ) - ): - raise ValueError( - "Username %r is not in Authenticator.allowed_users" - % username - ) - user = orm.User.find(db, name=username) - if user is None: - raise ValueError("%r does not exist" % username) - else: - roles.add_user(db, user=user, role=role) - # handle services - if 'services' in predef_role.keys(): - for servicename in predef_role['services']: - service = orm.Service.find(db, name=servicename) - if service is None: - raise ValueError("%r does not exist" % servicename) - else: - roles.add_user(db, user=service, role=role) - - # make sure all users and services have at least one role (update with default) - Classes = [orm.User, orm.Service] - for ormClass in Classes: - for obj in db.query(ormClass): + # make sure all users, services and tokens have at least one role (update with default) + for bearer in role_bearers: + Class = roles.get_orm_class(bearer) + for obj in db.query(Class): if len(obj.roles) < 1: - roles.update_roles(db, obj) - + roles.update_roles(db, obj=obj, kind=bearer) db.commit() async def _add_tokens(self, token_dict, kind): diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index c8b2dc9e..89722008 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -30,6 +30,7 @@ from tornado.web import RequestHandler from .. import __version__ from .. import orm +from .. import roles from ..metrics import PROXY_ADD_DURATION_SECONDS from ..metrics import PROXY_DELETE_DURATION_SECONDS from ..metrics import ProxyDeleteStatus @@ -453,6 +454,7 @@ class BaseHandler(RequestHandler): # not found, create and register user u = orm.User(name=username) self.db.add(u) + roles.update_roles(self.db, obj=u, kind='users') self.db.commit() user = self._user_from_orm(u) return user @@ -722,6 +724,7 @@ class BaseHandler(RequestHandler): # Only set `admin` if the authenticator returned an explicit value. if admin is not None and admin != user.admin: user.admin = admin + roles.update_roles(self.db, obj=user, kind='users') self.db.commit() # always set auth_state and commit, # because there could be key-rotation or clearing of previous values diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 66d9f31d..90ca63f7 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -39,6 +39,9 @@ from sqlalchemy.types import Text from sqlalchemy.types import TypeDecorator from tornado.log import app_log +from .roles import add_role +from .roles import get_default_roles +from .roles import update_roles from .utils import compare_token from .utils import hash_token from .utils import new_token @@ -151,6 +154,18 @@ service_role_map = Table( Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), ) +# token:role many:many mapping table +api_token_role_map = Table( + 'api_token_role_map', + 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), +) + class Role(Base): """User Roles""" @@ -162,6 +177,7 @@ class Role(Base): scopes = Column(JSONList) users = relationship('User', secondary='user_role_map', backref='roles') services = relationship('Service', secondary='service_role_map', backref='roles') + tokens = relationship('APIToken', secondary='api_token_role_map', backref='roles') def __repr__(self): return "<%s %s (%s) - scopes: %s>" % ( @@ -570,6 +586,7 @@ class APIToken(Hashed, Base): token=None, user=None, service=None, + roles=None, note='', generated=True, expires_in=None, @@ -598,6 +615,14 @@ class APIToken(Hashed, Base): if expires_in is not None: orm_token.expires_at = cls.now() + timedelta(seconds=expires_in) db.add(orm_token) + # load default roles if they haven't been initiated + # correct to have this here? otherwise some tests fail + user_role = Role.find(db, 'user') + if not user_role: + default_roles = get_default_roles() + for role in default_roles: + add_role(db, role) + update_roles(db, obj=orm_token, kind='tokens', roles=roles) db.commit() return token diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index a56b3344..7f78067e 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -1,12 +1,12 @@ """Roles utils""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from .orm import Role +from . import orm def get_default_roles(): - """Returns a list of default roles dictionaries""" + """Returns a list of default role dictionaries""" default_roles = [ { @@ -43,50 +43,127 @@ def add_role(db, role_dict): """Adds a new role to database or modifies an existing one""" - role = Role.find(db, role_dict['name']) + if 'name' not in role_dict.keys(): + raise ValueError('Role must have a name') + else: + name = role_dict['name'] + role = orm.Role.find(db, name) + + description = role_dict.get('description') + scopes = role_dict.get('scopes') if role is None: - role = Role( - name=role_dict['name'], - description=role_dict['description'], - scopes=role_dict['scopes'], - ) + role = orm.Role(name=name, description=description, scopes=scopes,) db.add(role) else: - role.description = role_dict['description'] - role.scopes = role_dict['scopes'] - role.users = [] - role.services = [] + if description: + role.description = description + if scopes: + role.scopes = scopes 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 update_roles(db, user): - - """Updates roles if user has no role with default or when user admin status is changed""" - - user_role = Role.find(db, 'user') - admin_role = Role.find(db, 'admin') - - if user.admin: - if user_role in user.roles: - remove_user(db, user, user_role) - add_user(db, user, admin_role) +def get_orm_class(kind): + if kind == 'users': + Class = orm.User + elif kind == 'services': + Class = orm.Service + elif kind == 'tokens': + Class = orm.APIToken else: - if admin_role in user.roles: - remove_user(db, user, admin_role) - # only add user role if the user has no other roles - if len(user.roles) < 1: - add_user(db, user, user_role) - db.commit() + raise ValueError("kind must be users, services or tokens, not %r" % kind) + + return Class + + +def existing_only(func): + + """Decorator for checking if objects and roles exist""" + + def check_existence(db, objname, kind, rolename): + + Class = get_orm_class(kind) + obj = Class.find(db, objname) + role = orm.Role.find(db, rolename) + + if obj is None: + raise ValueError("%r of kind %r does not exist" % (objname, kind)) + elif role is None: + raise ValueError("Role %r does not exist" % rolename) + else: + func(db, obj, kind, role) + + return check_existence + + +@existing_only +def add_obj(db, objname, kind, rolename): + + """Adds a role for users, services or tokens""" + + if rolename not in objname.roles: + objname.roles.append(rolename) + db.commit() + + +@existing_only +def remove_obj(db, objname, kind, rolename): + + """Removes a role for users, services or tokens""" + + if rolename in objname.roles: + objname.roles.remove(rolename) + db.commit() + + +def switch_default_role(db, obj, kind, admin): + + """Switch between default user and admin roles for users/services""" + + user_role = orm.Role.find(db, 'user') + admin_role = orm.Role.find(db, 'admin') + + def add_and_remove(db, obj, kind, current_role, new_role): + + if current_role in obj.roles: + remove_obj(db, objname=obj.name, kind=kind, rolename=current_role.name) + # only add new default role if the user has no other roles + if len(obj.roles) < 1: + add_obj(db, objname=obj.name, kind=kind, rolename=new_role.name) + + if admin: + add_and_remove(db, obj, kind, user_role, admin_role) + else: + add_and_remove(db, obj, kind, admin_role, user_role) + + +def update_roles(db, obj, kind, roles=None): + + """Updates object's roles if specified, + assigns default if no roles specified""" + + Class = get_orm_class(kind) + user_role = orm.Role.find(db, 'user') + + if roles: + for rolename in roles: + if Class == orm.APIToken: + # FIXME - check if specified roles do not add permissions + # on top of the token owner's scopes + role = orm.Role.find(db, rolename) + if role: + role.tokens.append(obj) + else: + raise ValueError('Role %r does not exist' % rolename) + else: + add_obj(db, objname=obj.name, kind=kind, rolename=rolename) + else: + # tokens can have only 'user' role as default + # assign the default only for user tokens + if Class == orm.APIToken: + if len(obj.roles) < 1 and obj.user is not None: + user_role.tokens.append(obj) + db.commit() + # users and services can have 'user' or 'admin' roles as default + else: + switch_default_role(db, obj, kind, obj.admin) diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 003e193c..154a9922 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -312,7 +312,7 @@ class MockHub(JupyterHub): test_clean_db = Bool(True) def init_db(self): - """Ensure we start with a clean user list""" + """Ensure we start with a clean user & role list""" super().init_db() if self.test_clean_db: for user in self.db.query(orm.User): @@ -336,10 +336,10 @@ class MockHub(JupyterHub): user = self.db.query(orm.User).filter(orm.User.name == 'user').first() if user is None: user = orm.User(name='user') - user_role = orm.Role.find(self.db, 'user') - roles.add_user(self.db, user=user, role=user_role) self.db.add(user) self.db.commit() + roles.update_roles(self.db, obj=user, kind='users') + self.db.commit() def stop(self): super().stop() diff --git a/jupyterhub/tests/populate_db.py b/jupyterhub/tests/populate_db.py index fec3d7e9..2b5c6007 100644 --- a/jupyterhub/tests/populate_db.py +++ b/jupyterhub/tests/populate_db.py @@ -10,8 +10,6 @@ from datetime import datetime import jupyterhub from jupyterhub import orm -# FIXME - for later versions of jupyterhub add code to test roles - def populate_db(url): """Populate a jupyterhub database""" diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 966dadcf..9d6a5dca 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -70,7 +70,7 @@ async def test_referer_check(app): # add admin user user = find_user(app.db, 'admin') if user is None: - user = add_user(app.db, name='admin', admin=True, roles=['admin']) + user = add_user(app.db, name='admin', admin=True) cookies = await app.login_user('admin') r = await api_request( @@ -1159,7 +1159,7 @@ async def test_token_as_user_deprecated(app, as_user, for_user, status): # ensure both users exist u = add_user(app.db, app, name=as_user) if for_user != 'missing': - add_user(app.db, app, name=for_user) + for_user_obj = add_user(app.db, app, name=for_user) data = {'username': for_user} headers = {'Authorization': 'token %s' % u.new_api_token()} r = await api_request( @@ -1252,7 +1252,7 @@ async def test_token_for_user(app, as_user, for_user, status): # ensure both users exist u = add_user(app.db, app, name=as_user) if for_user != 'missing': - add_user(app.db, app, name=for_user) + for_user_obj = add_user(app.db, app, name=for_user) data = {'username': for_user} headers = {'Authorization': 'token %s' % u.new_api_token()} r = await api_request( @@ -1269,6 +1269,7 @@ async def test_token_for_user(app, as_user, for_user, status): if status != 200: return assert 'token' in reply + token_id = reply['id'] r = await api_request(app, 'users', for_user, 'tokens', token_id, headers=headers) r.raise_for_status() diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index 20e515a2..884921c0 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -56,6 +56,7 @@ async def test_default_server(app, named_servers): assert user_model == fill_user( { 'name': username, + 'roles': ['user'], 'auth_state': None, 'server': user.url, 'servers': { @@ -86,7 +87,7 @@ async def test_default_server(app, named_servers): user_model = normalize_user(r.json()) assert user_model == fill_user( - {'name': username, 'servers': {}, 'auth_state': None} + {'name': username, 'roles': ['user'], 'servers': {}, 'auth_state': None} ) @@ -117,6 +118,7 @@ async def test_create_named_server(app, named_servers): assert user_model == fill_user( { 'name': username, + 'roles': ['user'], 'auth_state': None, 'servers': { servername: { @@ -159,7 +161,7 @@ async def test_delete_named_server(app, named_servers): user_model = normalize_user(r.json()) assert user_model == fill_user( - {'name': username, 'auth_state': None, 'servers': {}} + {'name': username, 'roles': ['user'], 'auth_state': None, 'servers': {}} ) # wrapper Spawner is gone assert servername not in user.spawners diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index 4b6c12ae..e9a7975c 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -560,4 +560,3 @@ 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 index 1e4ebe1d..da398dc9 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -1,48 +1,87 @@ """Test roles""" -# import pytest +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import json + from pytest import mark from .. import orm from .. import roles from ..utils import maybe_future from .mocking import MockHub +from .utils import add_user +from .utils import api_request @mark.role def test_orm_roles(db): """Test orm roles setup""" + user_role = orm.Role.find(db, name='user') + if not user_role: + user_role = orm.Role(name='user') + db.add(user_role) + db.commit() + + service_role = orm.Role(name='service') + db.add(service_role) + db.commit() + user = orm.User(name='falafel') db.add(user) + db.commit() + service = orm.Service(name='kebab') db.add(service) - role = orm.Role(name='default') - db.add(role) db.commit() - assert role.users == [] - assert role.services == [] + + assert user_role.users == [] + assert user_role.services == [] + assert service_role.users == [] + assert service_role.services == [] assert user.roles == [] assert service.roles == [] - role.users.append(user) - role.services.append(service) + user_role.users.append(user) + service_role.services.append(service) db.commit() - assert role.users == [user] - assert user.roles == [role] - assert role.services == [service] - assert service.roles == [role] + assert user_role.users == [user] + assert user.roles == [user_role] + assert service_role.services == [service] + assert service.roles == [service_role] + # check token creation without specifying its role + # assigns it the default 'user' role + token = user.new_api_token() + user_token = orm.APIToken.find(db, token=token) + assert user_token in user_role.tokens + assert user_role in user_token.roles + + # check creating token with a specific role + token = service.new_api_token(roles=['service']) + service_token = orm.APIToken.find(db, token=token) + assert service_token in service_role.tokens + assert service_role in service_token.roles + + # check deleting user removes the user and the token from roles db.delete(user) db.commit() - assert role.users == [] - db.delete(role) + assert user_role.users == [] + assert user_token not in user_role.tokens + # check deleting the service token removes it from 'service' role + db.delete(service_token) + db.commit() + assert service_token not in service_role.tokens + # check deleting the 'service' role removes it from service roles + db.delete(service_role) db.commit() assert service.roles == [] + db.delete(service) db.commit() @mark.role -def test_orm_role_delete_cascade(db): +def test_orm_roles_delete_cascade(db): """Orm roles cascade""" user1 = orm.User(name='user1') user2 = orm.User(name='user2') @@ -148,8 +187,6 @@ async def test_load_roles_users(tmpdir, request): hub.authenticator.admin_users = ['admin'] hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel'] await hub.init_users() - for user in db.query(orm.User): - print(user.name) await hub.init_roles() # test if the 'user' role has been overwritten and assigned @@ -178,6 +215,11 @@ async def test_load_roles_users(tmpdir, request): @mark.role async def test_load_roles_services(tmpdir, request): + services = [ + {'name': 'cull_idle', 'api_token': 'some-token'}, + {'name': 'user_service', 'api_token': 'some-other-token'}, + {'name': 'admin_service', 'api_token': 'secret-token', 'admin': True}, + ] roles_to_load = [ { 'name': 'culler', @@ -190,22 +232,20 @@ async def test_load_roles_services(tmpdir, request): ssl_enabled = getattr(request.module, "ssl_enabled", False) if ssl_enabled: kwargs['internal_certs_location'] = str(tmpdir) - hub = MockHub(test_clean_db=False, **kwargs) + hub = MockHub(**kwargs) hub.init_db() db = hub.db - # add test services to db - services = [ - {'name': 'cull_idle', 'admin': False}, - {'name': 'user_service', 'admin': False}, - {'name': 'admin_service', 'admin': True}, - ] - for service_specs in services: - service = orm.Service.find(db, service_specs['name']) - if service is None: - service = orm.Service( - name=service_specs['name'], admin=service_specs['admin'] - ) - db.add(service) + # clean db of previous services and add testing ones + for service in db.query(orm.Service): + db.delete(service) + db.commit() + for service in services: + orm_service = orm.Service.find(db, name=service['name']) + if orm_service is None: + # not found, create a new one + orm_service = orm.Service(name=service['name']) + db.add(orm_service) + orm_service.admin = service.get('admin', False) db.commit() await hub.init_roles() @@ -224,3 +264,104 @@ async def test_load_roles_services(tmpdir, request): cull_idle = orm.Service.find(db, name='cull_idle') assert culler_role in cull_idle.roles assert user_role not in cull_idle.roles + + # delete the test services + for service in db.query(orm.Service): + db.delete(service) + db.commit() + + +@mark.role +async def test_load_roles_tokens(tmpdir, request): + services = [ + {'name': 'cull_idle', 'admin': True, 'api_token': 'another-secret-token'} + ] + user_tokens = { + 'secret-token': 'cyclops', + 'super-secret-token': 'admin', + } + service_tokens = { + 'another-secret-token': 'cull_idle', + } + roles_to_load = [ + { + 'name': 'culler', + 'description': 'Cull idle servers', + 'scopes': ['users:servers', 'admin:servers'], + 'tokens': ['another-secret-token'], + }, + ] + kwargs = { + 'load_roles': roles_to_load, + 'services': services, + 'api_tokens': user_tokens, + 'service_tokens': service_tokens, + } + ssl_enabled = getattr(request.module, "ssl_enabled", False) + if ssl_enabled: + kwargs['internal_certs_location'] = str(tmpdir) + hub = MockHub(**kwargs) + hub.init_db() + db = hub.db + hub.authenticator.admin_users = ['admin'] + hub.authenticator.allowed_users = ['cyclops', 'gandalf'] + await hub.init_users() + await hub.init_api_tokens() + await hub.init_roles() + + # test if another-secret-token has culler role + service = orm.Service.find(db, 'cull_idle') + culler_role = orm.Role.find(db, 'culler') + token = orm.APIToken.find(db, 'another-secret-token') + assert len(token.roles) == 1 + assert culler_role in token.roles + + # test if all other tokens have default 'user' role + user_role = orm.Role.find(db, 'user') + sec_token = orm.APIToken.find(db, 'secret-token') + assert user_role in sec_token.roles + s_sec_token = orm.APIToken.find(db, 'super-secret-token') + assert user_role in s_sec_token.roles + + +@mark.role +@mark.parametrize( + "headers, role_list, status", + [ + ({}, None, 200), + ({}, ['reader'], 200), + ({}, ['non-existing'], 404), + # FIXME - add requesting token with 'not allowed' role + # granting more permission than the token owner has + ], +) +async def test_get_new_token_via_api(app, headers, role_list, status): + user = add_user(app.db, app, name='user') + roles.add_role(app.db, {'name': 'reader', 'scopes': ['read:all']}) + if role_list: + body = json.dumps({'roles': role_list}) + else: + body = '' + # request a new token + r = await api_request( + app, 'users/user/tokens', method='post', headers=headers, data=body + ) + assert r.status_code == status + if status != 200: + return + # check the new-token reply for roles + reply = r.json() + assert 'token' in reply + assert reply['user'] == 'user' + if not role_list: + assert reply['roles'] == ['user'] + else: + assert reply['roles'] == ['reader'] + token_id = reply['id'] + + # delete the token + r = await api_request(app, 'users/user/tokens', token_id, method='delete') + assert r.status_code == 204 + # verify deletion + r = await api_request(app, 'users/user/tokens', token_id) + assert r.status_code == 404 diff --git a/jupyterhub/tests/utils.py b/jupyterhub/tests/utils.py index 09aeb196..4c542871 100644 --- a/jupyterhub/tests/utils.py +++ b/jupyterhub/tests/utils.py @@ -6,6 +6,7 @@ from certipy import Certipy from jupyterhub import orm from jupyterhub.objects import Server +from jupyterhub.roles import update_roles from jupyterhub.utils import url_path_join as ujoin @@ -101,6 +102,8 @@ def add_user(db, app=None, **kwargs): for attr, value in kwargs.items(): setattr(orm_user, attr, value) db.commit() + requested_roles = kwargs.get('roles') + update_roles(db, obj=orm_user, kind='users', roles=requested_roles) if app: return app.users[orm_user.id] else: From de04ae147195e2c9ebcb9d8c82aa117c213edfbe Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Tue, 1 Dec 2020 08:44:29 +0100 Subject: [PATCH 07/10] verifying api requested token roles permissions against the token owner permissions --- jupyterhub/apihandlers/users.py | 8 ++- jupyterhub/roles.py | 105 ++++++++++++++++++++++++++++++-- jupyterhub/tests/test_roles.py | 8 +-- 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 2a072190..226be587 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -302,8 +302,14 @@ class UserTokenListAPIHandler(APIHandler): api_token = user.new_api_token( note=note, expires_in=body.get('expires_in', None), roles=token_roles ) - except ValueError: + except NameError: raise web.HTTPError(404, "Requested roles %r not found" % token_roles) + except ValueError: + raise web.HTTPError( + 403, + "Requested token roles %r have higher permissions than the token owner" + % token_roles, + ) if requester is not user: self.log.info( "%s %s requested API token for %s", diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 7f78067e..96fe5284 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -1,6 +1,8 @@ """Roles utils""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from itertools import chain + from . import orm @@ -39,6 +41,86 @@ def get_default_roles(): return default_roles +def get_scopes(): + + """ + Returns a dictionary of scopes: + scopes.keys() = scopes of highest level and scopes that have their own subscopes + scopes.values() = a list of first level subscopes or None + """ + + scopes = { + 'all': ['read:all'], + 'users': ['read:users', 'users:activity!user=username', 'users:servers'], + 'read:users': [ + 'read:users!user=username', + 'read:users:name', + 'read:users:groups', + 'read:users:activity', + 'read:users:servers', + ], + 'read:users:activity': ['read:users:activity!group=groupname'], + 'users:servers': ['users:servers!server=servername'], + 'users:tokens': ['read:users:tokens'], + 'admin:users': None, + 'admin:users:servers': None, + 'groups': ['groups!group=groupname', 'read:groups'], + 'admin:groups': None, + 'read:services': None, + 'proxy': None, + 'shutdown': None, + } + + return scopes + + +def expand_scope(scopename): + + """Returns a set of all subscopes""" + + scopes = get_scopes() + subscopes = [scopename] + + def expand_subscopes(index): + + more_subscopes = list( + filter(lambda scope: scope in scopes.keys(), subscopes[index:]) + ) + for scope in more_subscopes: + subscopes.extend(scopes[scope]) + + if scopename in scopes.keys() and scopes[scopename] is not None: + subscopes.extend(scopes[scopename]) + # record the index from where it should check for "subscopes of sub-subscopes" + index_for_sssc = len(subscopes) + # check for "subscopes of subscopes" + expand_subscopes(index=1) + # check for "subscopes of sub-subscopes" + expand_subscopes(index=index_for_sssc) + + expanded_scope = set(subscopes) + + return expanded_scope + + +def get_subscopes(role=None, roles=None): + + """Returns a set of all available subscopes for a specified role or list of roles""" + + scope_list = [] + if role: + scope_list = role.scopes + elif roles: + for role in roles: + scope_list.extend(role.scopes) + else: + raise ValueError('Function get_subscopes is missing an argument') + + scopes = list(chain.from_iterable(list(map(expand_scope, scope_list)))) + + return set(scopes) + + def add_role(db, role_dict): """Adds a new role to database or modifies an existing one""" @@ -148,13 +230,28 @@ def update_roles(db, obj, kind, roles=None): if roles: for rolename in roles: if Class == orm.APIToken: - # FIXME - check if specified roles do not add permissions - # on top of the token owner's scopes + role = orm.Role.find(db, rolename) if role: - role.tokens.append(obj) + # compare the requested role permissions with the owner's permissions (scopes) + token_scopes = get_subscopes(role=role) + # find the owner and their roles + owner = None + if obj.user_id: + owner = db.query(orm.User).get(obj.user_id) + elif obj.service_id: + owner = db.query(orm.Service).get(obj.service_id) + if owner: + owner_scopes = get_subscopes(roles=owner.roles) + if token_scopes.issubset(owner_scopes): + role.tokens.append(obj) + else: + raise ValueError( + 'Requested token role %r has higher permissions than the token owner' + % rolename + ) else: - raise ValueError('Role %r does not exist' % rolename) + raise NameError('Role %r does not exist' % rolename) else: add_obj(db, objname=obj.name, kind=kind, rolename=rolename) else: diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index da398dc9..340647eb 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -18,11 +18,11 @@ def test_orm_roles(db): """Test orm roles setup""" user_role = orm.Role.find(db, name='user') if not user_role: - user_role = orm.Role(name='user') + user_role = orm.Role(name='user', scopes=['all', 'read:all']) db.add(user_role) db.commit() - service_role = orm.Role(name='service') + service_role = orm.Role(name='service', scopes=['users:servers']) db.add(service_role) db.commit() @@ -331,13 +331,13 @@ async def test_load_roles_tokens(tmpdir, request): ({}, None, 200), ({}, ['reader'], 200), ({}, ['non-existing'], 404), - # FIXME - add requesting token with 'not allowed' role - # granting more permission than the token owner has + ({}, ['user_creator'], 403), ], ) async def test_get_new_token_via_api(app, headers, role_list, status): user = add_user(app.db, app, name='user') roles.add_role(app.db, {'name': 'reader', 'scopes': ['read:all']}) + roles.add_role(app.db, {'name': 'user_creator', 'scopes': ['admin:users']}) if role_list: body = json.dumps({'roles': role_list}) else: From ab297a7747e271affd8267d57352e5ce904c8587 Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Thu, 3 Dec 2020 14:53:53 +0100 Subject: [PATCH 08/10] added scope expansion unit testing --- jupyterhub/roles.py | 7 ++---- jupyterhub/tests/test_roles.py | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 96fe5284..aabbf7bd 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -51,20 +51,17 @@ def get_scopes(): scopes = { 'all': ['read:all'], - 'users': ['read:users', 'users:activity!user=username', 'users:servers'], + 'users': ['read:users', 'users:activity', 'users:servers'], 'read:users': [ - 'read:users!user=username', 'read:users:name', 'read:users:groups', 'read:users:activity', 'read:users:servers', ], - 'read:users:activity': ['read:users:activity!group=groupname'], - 'users:servers': ['users:servers!server=servername'], 'users:tokens': ['read:users:tokens'], 'admin:users': None, 'admin:users:servers': None, - 'groups': ['groups!group=groupname', 'read:groups'], + 'groups': ['read:groups'], 'admin:groups': None, 'read:services': None, 'proxy': None, diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index 340647eb..bdc8b98a 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -144,6 +144,45 @@ def test_orm_roles_delete_cascade(db): @mark.role +@mark.parametrize( + "scopes, subscopes", + [ + ( + ['users'], + { + 'users', + 'read:users', + 'users:activity', + 'users:servers', + 'read:users:name', + 'read:users:groups', + 'read:users:activity', + 'read:users:servers', + }, + ), + ( + ['read:users'], + { + 'read:users', + 'read:users:name', + 'read:users:groups', + 'read:users:activity', + 'read:users:servers', + }, + ), + (['read:users:servers'], {'read:users:servers'}), + (['admin:groups'], {'admin:groups'}), + ], +) +def test_get_subscopes(db, scopes, subscopes): + """Test role scopes expansion into their subscopes""" + roles.add_role(db, {'name': 'testing_scopes', 'scopes': scopes}) + role = orm.Role.find(db, name='testing_scopes') + response = roles.get_subscopes(role) + assert response == subscopes + db.delete(role) + + async def test_load_default_roles(tmpdir, request): """Test loading default roles in app.py""" kwargs = {} From c514259f1aead2a939bba836648b9d655433d19c Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Tue, 8 Dec 2020 08:28:23 +0100 Subject: [PATCH 09/10] addressed review comments from Omar --- jupyterhub/roles.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index aabbf7bd..74eb5a11 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -28,6 +28,7 @@ def get_default_roles(): 'groups', 'admin:groups', 'read:services', + 'read:hub', 'proxy', 'shutdown', ], @@ -64,6 +65,7 @@ def get_scopes(): 'groups': ['read:groups'], 'admin:groups': None, 'read:services': None, + 'read:hub': None, 'proxy': None, 'shutdown': None, } @@ -100,22 +102,18 @@ def expand_scope(scopename): return expanded_scope -def get_subscopes(role=None, roles=None): +def get_subscopes(*args): """Returns a set of all available subscopes for a specified role or list of roles""" scope_list = [] - if role: - scope_list = role.scopes - elif roles: - for role in roles: - scope_list.extend(role.scopes) - else: - raise ValueError('Function get_subscopes is missing an argument') - scopes = list(chain.from_iterable(list(map(expand_scope, scope_list)))) + for role in args: + scope_list.extend(role.scopes) - return set(scopes) + scopes = set(chain.from_iterable(list(map(expand_scope, scope_list)))) + + return scopes def add_role(db, role_dict): @@ -132,7 +130,7 @@ def add_role(db, role_dict): scopes = role_dict.get('scopes') if role is None: - role = orm.Role(name=name, description=description, scopes=scopes,) + role = orm.Role(name=name, description=description, scopes=scopes) db.add(role) else: if description: @@ -231,7 +229,7 @@ def update_roles(db, obj, kind, roles=None): role = orm.Role.find(db, rolename) if role: # compare the requested role permissions with the owner's permissions (scopes) - token_scopes = get_subscopes(role=role) + token_scopes = get_subscopes(role) # find the owner and their roles owner = None if obj.user_id: @@ -239,7 +237,7 @@ def update_roles(db, obj, kind, roles=None): elif obj.service_id: owner = db.query(orm.Service).get(obj.service_id) if owner: - owner_scopes = get_subscopes(roles=owner.roles) + owner_scopes = get_subscopes(*owner.roles) if token_scopes.issubset(owner_scopes): role.tokens.append(obj) else: From 9de90706415200bf3a3861e69d07d271d1d72746 Mon Sep 17 00:00:00 2001 From: IvanaH8 Date: Wed, 9 Dec 2020 14:50:50 +0100 Subject: [PATCH 10/10] fixed scope test attr error for older_requirements.txt test --- jupyterhub/roles.py | 2 +- jupyterhub/utils.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 74eb5a11..544aa08f 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -217,7 +217,7 @@ def switch_default_role(db, obj, kind, admin): def update_roles(db, obj, kind, roles=None): """Updates object's roles if specified, - assigns default if no roles specified""" + assigns default if no roles specified""" Class = get_orm_class(kind) user_role = orm.Role.find(db, 'user') diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 26bae333..0de66778 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -406,9 +406,15 @@ def needs_scope(scope): if check_scope(self, scope, parsed_scopes, **s_kwargs): return func(self, *args, **kwargs) else: + # catching attr error occurring for older_requirements test + # could be done more ellegantly? + try: + request_path = self.request.path + except AttributeError: + request_path = 'the requested API' app_log.warning( "Not authorizing access to {}. Requires scope {}, not derived from scopes {}".format( - self.request.path, scope, ", ".join(self.scopes) + request_path, scope, ", ".join(self.scopes) ) ) raise web.HTTPError(