mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Merge branch 'main' into allow_all
This commit is contained in:
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
@@ -11,46 +11,11 @@ on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
# Declare bash be used by default in this workflow's "run" steps.
|
||||
#
|
||||
# NOTE: bash will by default run with:
|
||||
# --noprofile: Ignore ~/.profile etc.
|
||||
# --norc: Ignore ~/.bashrc etc.
|
||||
# -e: Exit directly on errors
|
||||
# -o pipefail: Don't mask errors from a command piped into another command
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||
LANG: C.UTF-8
|
||||
|
||||
jobs:
|
||||
# Run "pre-commit run --all-files"
|
||||
pre-commit:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
# ref: https://github.com/pre-commit/action
|
||||
- uses: pre-commit/action@v2.0.0
|
||||
- name: Help message if pre-commit fail
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
echo "You can install pre-commit hooks to automatically run formatting"
|
||||
echo "on each commit with:"
|
||||
echo " pre-commit install"
|
||||
echo "or you can run by hand on staged files with"
|
||||
echo " pre-commit run"
|
||||
echo "or after-the-fact on already committed files with"
|
||||
echo " pre-commit run --all-files"
|
||||
|
||||
# Run "pytest jupyterhub/tests" in various configurations
|
||||
pytest:
|
||||
runs-on: ubuntu-20.04
|
||||
|
@@ -1,22 +1,22 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v1.9.0
|
||||
rev: v2.5.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 20.8b1
|
||||
rev: 21.6b0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.2.1
|
||||
rev: v2.3.2
|
||||
hooks:
|
||||
- id: prettier
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: "3.8.4"
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "3.9.2"
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.4.0
|
||||
rev: v4.0.1
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-case-conflict
|
||||
|
@@ -20,7 +20,7 @@ fi
|
||||
|
||||
# Configure a set of databases in the database server for upgrade tests
|
||||
set -x
|
||||
for SUFFIX in '' _upgrade_072 _upgrade_081 _upgrade_094; do
|
||||
for SUFFIX in '' _upgrade_100 _upgrade_122 _upgrade_130; do
|
||||
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
||||
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
||||
done
|
||||
|
@@ -66,7 +66,12 @@ metrics: source/reference/metrics.rst
|
||||
source/reference/metrics.rst: generate-metrics.py
|
||||
python3 generate-metrics.py
|
||||
|
||||
html: rest-api metrics
|
||||
scopes: source/rbac/scope-table.md
|
||||
|
||||
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
||||
python3 source/rbac/generate-scope-table.py
|
||||
|
||||
html: rest-api metrics scopes
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
@@ -4,9 +4,9 @@ alabaster_jupyterhub
|
||||
# Temporary fix of #3021. Revert back to released autodoc-traits when
|
||||
# 0.1.0 released.
|
||||
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
|
||||
myst-parser
|
||||
pydata-sphinx-theme
|
||||
pytablewriter>=0.56
|
||||
recommonmark>=0.6
|
||||
sphinx>=1.7
|
||||
sphinx-copybutton
|
||||
sphinx-jsonschema
|
||||
|
@@ -12,8 +12,62 @@ securityDefinitions:
|
||||
type: apiKey
|
||||
name: Authorization
|
||||
in: header
|
||||
security:
|
||||
oauth2:
|
||||
type: oauth2
|
||||
flow: accessCode
|
||||
authorizationUrl: "/hub/api/oauth2/authorize" # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations?
|
||||
tokenUrl: "/hub/api/oauth2/token"
|
||||
scopes: # Generated based on scope table in jupyterhub/scopes.py
|
||||
(no_scope): Identify the owner of the requesting entity.
|
||||
self:
|
||||
The user’s own resources _(metascope for users, resolves to (no_scope)
|
||||
for services)_
|
||||
all: Everything that the token-owning entity can access _(metascope for tokens)_
|
||||
admin:users:
|
||||
Read, write, create and delete users and their authentication state,
|
||||
not including their servers or tokens.
|
||||
admin:auth_state: Read a user’s authentication state.
|
||||
users:
|
||||
Read and write permissions to user models (excluding servers, tokens
|
||||
and authentication state).
|
||||
read:users:
|
||||
Read user models (excluding including servers, tokens and authentication
|
||||
state).
|
||||
read:users:name: Read names of users.
|
||||
read:users:groups: Read users’ group membership.
|
||||
read:users:activity: Read time of last user activity.
|
||||
read:roles: Read role assignments.
|
||||
read:roles:users: Read user role assignments.
|
||||
read:roles:services: Read service role assignments.
|
||||
read:roles:groups: Read group role assignments.
|
||||
users:activity: Update time of last user activity.
|
||||
admin:servers: Read, start, stop, create and delete user servers and their state.
|
||||
admin:server_state: Read and write users’ server state.
|
||||
servers: Start and stop user servers.
|
||||
read:servers:
|
||||
Read users’ names and their server models (excluding the server
|
||||
state).
|
||||
tokens: Read, write, create and delete user tokens.
|
||||
read:tokens: Read user tokens.
|
||||
admin:groups: Read and write group information, create and delete groups.
|
||||
groups:
|
||||
Read and write group information, including adding/removing users to/from
|
||||
groups.
|
||||
read:groups: Read group models.
|
||||
read:groups:name: Read group names.
|
||||
read:services: Read service models.
|
||||
read:services:name: Read service names.
|
||||
read:hub: Read detailed information about the Hub.
|
||||
access:servers: Access user servers via API or browser.
|
||||
access:services: Access services via API or browser.
|
||||
proxy:
|
||||
Read information about the proxy’s routing table, sync the Hub with the
|
||||
proxy and notify the Hub about a new proxy.
|
||||
shutdown: Shutdown the hub.
|
||||
security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope self) or have both (either can be used)?
|
||||
- token: []
|
||||
- oauth2:
|
||||
- self
|
||||
basePath: /hub/api
|
||||
produces:
|
||||
- application/json
|
||||
@@ -38,6 +92,9 @@ paths:
|
||||
/info:
|
||||
get:
|
||||
summary: Get detailed info about JupyterHub
|
||||
security:
|
||||
- oauth2:
|
||||
- read:hub
|
||||
description: |
|
||||
Detailed JupyterHub information, including Python version,
|
||||
JupyterHub's version and executable path,
|
||||
@@ -62,7 +119,9 @@ paths:
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
description: The Python class currently active for JupyterHub Authentication
|
||||
description:
|
||||
The Python class currently active for JupyterHub
|
||||
Authentication
|
||||
version:
|
||||
type: string
|
||||
description: The version of the currently active Authenticator
|
||||
@@ -71,13 +130,25 @@ paths:
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
description: The Python class currently active for spawning single-user notebook servers
|
||||
description:
|
||||
The Python class currently active for spawning single-user
|
||||
notebook servers
|
||||
version:
|
||||
type: string
|
||||
description: The version of the currently active Spawner
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
security:
|
||||
- oauth2:
|
||||
- read:users
|
||||
- read:users:name
|
||||
- read:users:groups
|
||||
- read:users:activity
|
||||
- read:servers
|
||||
- read:roles:users
|
||||
- admin:auth_state
|
||||
- admin:server_state
|
||||
parameters:
|
||||
- name: state
|
||||
in: query
|
||||
@@ -118,6 +189,9 @@ paths:
|
||||
$ref: "#/definitions/User"
|
||||
post:
|
||||
summary: Create multiple users
|
||||
security:
|
||||
- oauth2:
|
||||
- admin:users
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
@@ -144,6 +218,16 @@ paths:
|
||||
/users/{name}:
|
||||
get:
|
||||
summary: Get a user by name
|
||||
security:
|
||||
- oauth2:
|
||||
- read:users
|
||||
- read:users:name
|
||||
- read:users:groups
|
||||
- read:users:activity
|
||||
- read:servers
|
||||
- read:roles:users
|
||||
- admin:auth_state
|
||||
- admin:server_state
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -157,6 +241,9 @@ paths:
|
||||
$ref: "#/definitions/User"
|
||||
post:
|
||||
summary: Create a single user
|
||||
security:
|
||||
- oauth2:
|
||||
- admin:users
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -171,6 +258,9 @@ paths:
|
||||
patch:
|
||||
summary: Modify a user
|
||||
description: Change a user's name or admin status
|
||||
security:
|
||||
- oauth2:
|
||||
- admin:users
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -180,16 +270,22 @@ paths:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
description: Updated user info. At least one key to be updated (name or admin) is required.
|
||||
description:
|
||||
Updated user info. At least one key to be updated (name or admin)
|
||||
is required.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: the new name (optional, if another key is updated i.e. admin)
|
||||
description:
|
||||
the new name (optional, if another key is updated i.e.
|
||||
admin)
|
||||
admin:
|
||||
type: boolean
|
||||
description: update admin (optional, if another key is updated i.e. name)
|
||||
description:
|
||||
update admin (optional, if another key is updated i.e.
|
||||
name)
|
||||
responses:
|
||||
"200":
|
||||
description: The updated user info
|
||||
@@ -197,6 +293,9 @@ paths:
|
||||
$ref: "#/definitions/User"
|
||||
delete:
|
||||
summary: Delete a user
|
||||
security:
|
||||
- oauth2:
|
||||
- admin:users
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -209,9 +308,12 @@ paths:
|
||||
/users/{name}/activity:
|
||||
post:
|
||||
summary: Notify Hub of activity for a given user.
|
||||
description: Notify the Hub of activity by the user,
|
||||
e.g. accessing a service or (more likely)
|
||||
actively using a server.
|
||||
description:
|
||||
Notify the Hub of activity by the user, e.g. accessing a service
|
||||
or (more likely) actively using a server.
|
||||
security:
|
||||
- oauth2:
|
||||
- users:activity
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -264,6 +366,9 @@ paths:
|
||||
/users/{name}/server:
|
||||
post:
|
||||
summary: Start a user's single-user notebook server
|
||||
security:
|
||||
- oauth2:
|
||||
- servers
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -287,9 +392,14 @@ paths:
|
||||
"201":
|
||||
description: The user's notebook server has started
|
||||
"202":
|
||||
description: The user's notebook server has not yet started, but has been requested
|
||||
description:
|
||||
The user's notebook server has not yet started, but has been
|
||||
requested
|
||||
delete:
|
||||
summary: Stop a user's server
|
||||
security:
|
||||
- oauth2:
|
||||
- servers
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -300,10 +410,15 @@ paths:
|
||||
"204":
|
||||
description: The user's notebook server has stopped
|
||||
"202":
|
||||
description: The user's notebook server has not yet stopped as it is taking a while to stop
|
||||
description:
|
||||
The user's notebook server has not yet stopped as it is taking
|
||||
a while to stop
|
||||
/users/{name}/servers/{server_name}:
|
||||
post:
|
||||
summary: Start a user's single-user named-server notebook server
|
||||
security:
|
||||
- oauth2:
|
||||
- servers
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -332,9 +447,14 @@ paths:
|
||||
"201":
|
||||
description: The user's notebook named-server has started
|
||||
"202":
|
||||
description: The user's notebook named-server has not yet started, but has been requested
|
||||
description:
|
||||
The user's notebook named-server has not yet started, but has
|
||||
been requested
|
||||
delete:
|
||||
summary: Stop a user's named-server
|
||||
security:
|
||||
- oauth2:
|
||||
- servers
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -362,7 +482,9 @@ paths:
|
||||
"204":
|
||||
description: The user's notebook named-server has stopped
|
||||
"202":
|
||||
description: The user's notebook named-server has not yet stopped as it is taking a while to stop
|
||||
description:
|
||||
The user's notebook named-server has not yet stopped as it
|
||||
is taking a while to stop
|
||||
/users/{name}/tokens:
|
||||
parameters:
|
||||
- name: name
|
||||
@@ -372,6 +494,9 @@ paths:
|
||||
type: string
|
||||
get:
|
||||
summary: List tokens for the user
|
||||
security:
|
||||
- oauth2:
|
||||
- read:tokens
|
||||
responses:
|
||||
"200":
|
||||
description: The list of tokens
|
||||
@@ -385,6 +510,9 @@ paths:
|
||||
description: No such user
|
||||
post:
|
||||
summary: Create a new token for the user
|
||||
security:
|
||||
- oauth2:
|
||||
- tokens
|
||||
parameters:
|
||||
- name: token_params
|
||||
in: body
|
||||
@@ -394,10 +522,17 @@ paths:
|
||||
properties:
|
||||
expires_in:
|
||||
type: number
|
||||
description: lifetime (in seconds) after which the requested token will expire.
|
||||
description:
|
||||
lifetime (in seconds) after which the requested token will
|
||||
expire.
|
||||
note:
|
||||
type: string
|
||||
description: A note attached to the token for future bookkeeping
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: A list of role names that the token should have
|
||||
responses:
|
||||
"201":
|
||||
description: The newly created token
|
||||
@@ -405,6 +540,8 @@ paths:
|
||||
$ref: "#/definitions/Token"
|
||||
"400":
|
||||
description: Body must be a JSON dict or empty
|
||||
"403":
|
||||
description: Requested role does not exist
|
||||
/users/{name}/tokens/{token_id}:
|
||||
parameters:
|
||||
- name: name
|
||||
@@ -418,6 +555,9 @@ paths:
|
||||
type: string
|
||||
get:
|
||||
summary: Get the model for a token by id
|
||||
security:
|
||||
- oauth2:
|
||||
- read:tokens
|
||||
responses:
|
||||
"200":
|
||||
description: The info for the new token
|
||||
@@ -425,12 +565,25 @@ paths:
|
||||
$ref: "#/definitions/Token"
|
||||
delete:
|
||||
summary: Delete (revoke) a token by id
|
||||
security:
|
||||
- oauth2:
|
||||
- tokens
|
||||
responses:
|
||||
"204":
|
||||
description: The token has been deleted
|
||||
/user:
|
||||
get:
|
||||
summary: Return authenticated user's model
|
||||
security:
|
||||
- oauth2:
|
||||
- read:users
|
||||
- read:users:name
|
||||
- read:users:groups
|
||||
- read:users:activity
|
||||
- read:servers
|
||||
- read:roles:users
|
||||
- admin:auth_state
|
||||
- admin:server_state
|
||||
responses:
|
||||
"200":
|
||||
description: The authenticated user's model is returned.
|
||||
@@ -439,6 +592,11 @@ paths:
|
||||
/groups:
|
||||
get:
|
||||
summary: List groups
|
||||
security:
|
||||
- oauth2:
|
||||
- read:groups
|
||||
- read:groups:name
|
||||
- read:roles:groups
|
||||
parameters:
|
||||
- name: offset
|
||||
in: query
|
||||
@@ -466,6 +624,11 @@ paths:
|
||||
/groups/{name}:
|
||||
get:
|
||||
summary: Get a group by name
|
||||
security:
|
||||
- oauth2:
|
||||
- read:groups
|
||||
- read:groups:name
|
||||
- read:roles:groups
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -479,6 +642,9 @@ paths:
|
||||
$ref: "#/definitions/Group"
|
||||
post:
|
||||
summary: Create a group
|
||||
security:
|
||||
- oauth2:
|
||||
- admin:groups
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -492,6 +658,9 @@ paths:
|
||||
$ref: "#/definitions/Group"
|
||||
delete:
|
||||
summary: Delete a group
|
||||
security:
|
||||
- oauth2:
|
||||
- admin:groups
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -504,6 +673,9 @@ paths:
|
||||
/groups/{name}/users:
|
||||
post:
|
||||
summary: Add users to a group
|
||||
security:
|
||||
- oauth2:
|
||||
- groups
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -529,6 +701,9 @@ paths:
|
||||
$ref: "#/definitions/Group"
|
||||
delete:
|
||||
summary: Remove users from a group
|
||||
security:
|
||||
- oauth2:
|
||||
- groups
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -553,6 +728,11 @@ paths:
|
||||
/services:
|
||||
get:
|
||||
summary: List services
|
||||
security:
|
||||
- oauth2:
|
||||
- read:services
|
||||
- read:services:name
|
||||
- read:roles:services
|
||||
responses:
|
||||
"200":
|
||||
description: The service list
|
||||
@@ -563,6 +743,11 @@ paths:
|
||||
/services/{name}:
|
||||
get:
|
||||
summary: Get a service by name
|
||||
security:
|
||||
- oauth2:
|
||||
- read:services
|
||||
- read:services:name
|
||||
- read:roles:services
|
||||
parameters:
|
||||
- name: name
|
||||
description: service name
|
||||
@@ -577,7 +762,12 @@ paths:
|
||||
/proxy:
|
||||
get:
|
||||
summary: Get the proxy's routing table
|
||||
description: A convenience alias for getting the routing table directly from the proxy
|
||||
description:
|
||||
A convenience alias for getting the routing table directly from
|
||||
the proxy
|
||||
security:
|
||||
- oauth2:
|
||||
- proxy
|
||||
parameters:
|
||||
- name: offset
|
||||
in: query
|
||||
@@ -600,20 +790,30 @@ paths:
|
||||
description: Routing table
|
||||
schema:
|
||||
type: object
|
||||
description: configurable-http-proxy routing table (see configurable-http-proxy docs for details)
|
||||
description:
|
||||
configurable-http-proxy routing table (see configurable-http-proxy
|
||||
docs for details)
|
||||
post:
|
||||
summary: Force the Hub to sync with the proxy
|
||||
security:
|
||||
- oauth2:
|
||||
- proxy
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
patch:
|
||||
summary: Notify the Hub about a new proxy
|
||||
description: Notifies the Hub of a new proxy to use.
|
||||
security:
|
||||
- oauth2:
|
||||
- proxy
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
description: Any values that have changed for the new proxy. All keys are optional.
|
||||
description:
|
||||
Any values that have changed for the new proxy. All keys are
|
||||
optional.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
@@ -641,6 +841,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:
|
||||
- tokens
|
||||
parameters:
|
||||
- name: credentials
|
||||
in: body
|
||||
@@ -665,6 +868,9 @@ paths:
|
||||
/authorizations/token/{token}:
|
||||
get:
|
||||
summary: Identify a user or service from an API token
|
||||
security:
|
||||
- oauth2:
|
||||
- (noscope)
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
@@ -678,7 +884,9 @@ paths:
|
||||
/authorizations/cookie/{cookie_name}/{cookie_value}:
|
||||
get:
|
||||
summary: Identify a user from a cookie
|
||||
description: Used by single-user notebook servers to hand off cookie authentication to the Hub
|
||||
description:
|
||||
Used by single-user notebook servers to hand off cookie authentication
|
||||
to the Hub
|
||||
parameters:
|
||||
- name: cookie_name
|
||||
in: path
|
||||
@@ -695,6 +903,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"
|
||||
@@ -776,6 +985,9 @@ paths:
|
||||
/shutdown:
|
||||
post:
|
||||
summary: Shutdown the Hub
|
||||
security:
|
||||
- oauth2:
|
||||
- shutdown
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
@@ -784,10 +996,14 @@ paths:
|
||||
properties:
|
||||
proxy:
|
||||
type: boolean
|
||||
description: Whether the proxy should be shutdown as well (default from Hub config)
|
||||
description:
|
||||
Whether the proxy should be shutdown as well (default from
|
||||
Hub config)
|
||||
servers:
|
||||
type: boolean
|
||||
description: Whether users' notebook servers should be shutdown as well (default from Hub config)
|
||||
description:
|
||||
Whether users' notebook servers should be shutdown as well
|
||||
(default from Hub config)
|
||||
responses:
|
||||
"202":
|
||||
description: Shutdown successful
|
||||
@@ -809,6 +1025,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
|
||||
@@ -830,12 +1051,20 @@ definitions:
|
||||
description: The active servers for this user.
|
||||
items:
|
||||
$ref: "#/definitions/Server"
|
||||
auth_state:
|
||||
type: string
|
||||
#TODO: will there be predefined states? Should it rather be object instead of string?
|
||||
description:
|
||||
Authentication state of the user. Only available with admin:users:auth_state
|
||||
scope. None otherwise.
|
||||
Server:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The server's name. The user's default server has an empty name ('')
|
||||
description:
|
||||
The server's name. The user's default server has an empty name
|
||||
('')
|
||||
ready:
|
||||
type: boolean
|
||||
description: |
|
||||
@@ -866,10 +1095,15 @@ definitions:
|
||||
description: UTC timestamp last-seen activity on this server.
|
||||
state:
|
||||
type: object
|
||||
description: Arbitrary internal state from this server's spawner. Only available on the hub's users list or get-user-by-name method, and only if a hub admin. None otherwise.
|
||||
description:
|
||||
Arbitrary internal state from this server's spawner. Only available
|
||||
on the hub's users list or get-user-by-name method, and only with admin:users:server_state
|
||||
scope. None otherwise.
|
||||
user_options:
|
||||
type: object
|
||||
description: User specified options for the user's spawned instance of a single-user server.
|
||||
description:
|
||||
User specified options for the user's spawned instance of a single-user
|
||||
server.
|
||||
Group:
|
||||
type: object
|
||||
properties:
|
||||
@@ -881,6 +1115,11 @@ definitions:
|
||||
description: The names of users who are members of this group
|
||||
items:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
description: The names of roles this group has
|
||||
items:
|
||||
type: string
|
||||
Service:
|
||||
type: object
|
||||
properties:
|
||||
@@ -890,6 +1129,11 @@ definitions:
|
||||
admin:
|
||||
type: boolean
|
||||
description: Whether the service is an admin
|
||||
roles:
|
||||
type: array
|
||||
description: The names of roles this service has
|
||||
items:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
description: The internal url where the service is running
|
||||
@@ -914,7 +1158,9 @@ definitions:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: The token itself. Only present in responses to requests for a new token.
|
||||
description:
|
||||
The token itself. Only present in responses to requests for a
|
||||
new token.
|
||||
id:
|
||||
type: string
|
||||
description: The id of the API token. Used for modifying or deleting the token.
|
||||
@@ -923,10 +1169,17 @@ definitions:
|
||||
description: The user that owns a token (undefined if owned by a service)
|
||||
service:
|
||||
type: string
|
||||
description: The service that owns the token (undefined if owned by a user)
|
||||
description: The service that owns the token (undefined of owned by a user)
|
||||
roles:
|
||||
type: array
|
||||
description: The names of roles this token has
|
||||
items:
|
||||
type: string
|
||||
note:
|
||||
type: string
|
||||
description: A note about the token, typically describing what it was created for.
|
||||
description:
|
||||
A note about the token, typically describing what it was created
|
||||
for.
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
|
@@ -509,12 +509,11 @@ whether it was through discussion, testing, documentation, or development.
|
||||
allowing the Authenticator to _require_ that authentication data is fresh
|
||||
immediately before the user's server is launched.
|
||||
|
||||
```eval_rst
|
||||
.. seealso::
|
||||
```{seealso}
|
||||
|
||||
- :meth:`.Authenticator.refresh_user`
|
||||
- :meth:`.Spawner.create_certs`
|
||||
- :meth:`.Spawner.move_certs`
|
||||
- {meth}`.Authenticator.refresh_user`
|
||||
- {meth}`.Spawner.create_certs`
|
||||
- {meth}`.Spawner.move_certs`
|
||||
```
|
||||
|
||||
#### New features
|
||||
|
@@ -19,7 +19,7 @@ extensions = [
|
||||
'autodoc_traits',
|
||||
'sphinx_copybutton',
|
||||
'sphinx-jsonschema',
|
||||
'recommonmark',
|
||||
'myst_parser',
|
||||
]
|
||||
|
||||
# The master toctree document.
|
||||
@@ -52,11 +52,6 @@ todo_include_todos = False
|
||||
# Set the default role so we can use `foo` instead of ``foo``
|
||||
default_role = 'literal'
|
||||
|
||||
# -- Source -------------------------------------------------------------
|
||||
|
||||
import recommonmark
|
||||
from recommonmark.transform import AutoStructify
|
||||
|
||||
# -- Config -------------------------------------------------------------
|
||||
from jupyterhub.app import JupyterHub
|
||||
from docutils import nodes
|
||||
@@ -111,9 +106,7 @@ class HelpAllDirective(SphinxDirective):
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.add_config_value('recommonmark_config', {'enable_eval_rst': True}, True)
|
||||
app.add_css_file('custom.css')
|
||||
app.add_transform(AutoStructify)
|
||||
app.add_directive('jupyterhub-generate-config', ConfigDirective)
|
||||
app.add_directive('jupyterhub-help-all', HelpAllDirective)
|
||||
|
||||
@@ -219,7 +212,7 @@ if on_rtd:
|
||||
# build both metrics and rest-api, since RTD doesn't run make
|
||||
from subprocess import check_call as sh
|
||||
|
||||
sh(['make', 'metrics', 'rest-api'], cwd=docs)
|
||||
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
|
||||
|
||||
# -- Spell checking -------------------------------------------------------
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Frequently asked questions
|
||||
|
||||
### How do I share links to notebooks?
|
||||
## How do I share links to notebooks?
|
||||
|
||||
In short, where you see `/user/name/notebooks/foo.ipynb` use `/hub/user-redirect/notebooks/foo.ipynb` (replace `/user/name` with `/hub/user-redirect`).
|
||||
|
||||
|
BIN
docs/source/images/rbac-api-request-chart.png
Normal file
BIN
docs/source/images/rbac-api-request-chart.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 446 KiB |
BIN
docs/source/images/rbac-token-request-chart.png
Normal file
BIN
docs/source/images/rbac-token-request-chart.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 483 KiB |
@@ -108,6 +108,14 @@ API Reference
|
||||
|
||||
api/index
|
||||
|
||||
RBAC Reference
|
||||
--------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
rbac/index
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
|
126
docs/source/rbac/generate-scope-table.py
Normal file
126
docs/source/rbac/generate-scope-table.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from pytablewriter import MarkdownTableWriter
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from jupyterhub.scopes import scope_definitions
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
PARENT = Path(HERE).parent.parent.absolute()
|
||||
|
||||
|
||||
class ScopeTableGenerator:
|
||||
def __init__(self):
|
||||
self.scopes = scope_definitions
|
||||
|
||||
@classmethod
|
||||
def create_writer(cls, table_name, headers, values):
|
||||
writer = MarkdownTableWriter()
|
||||
writer.table_name = table_name
|
||||
writer.headers = headers
|
||||
writer.value_matrix = values
|
||||
writer.margin = 1
|
||||
return writer
|
||||
|
||||
def _get_scope_relationships(self):
|
||||
"""Returns a tuple of dictionary of all scope-subscope pairs and a list of just subscopes:
|
||||
|
||||
({scope: subscope}, [subscopes])
|
||||
|
||||
used for creating hierarchical scope table in _parse_scopes()
|
||||
"""
|
||||
pairs = []
|
||||
for scope, data in self.scopes.items():
|
||||
subscopes = data.get('subscopes')
|
||||
if subscopes is not None:
|
||||
for subscope in subscopes:
|
||||
pairs.append((scope, subscope))
|
||||
else:
|
||||
pairs.append((scope, None))
|
||||
subscopes = [pair[1] for pair in pairs]
|
||||
pairs_dict = defaultdict(list)
|
||||
for scope, subscope in pairs:
|
||||
pairs_dict[scope].append(subscope)
|
||||
return pairs_dict, subscopes
|
||||
|
||||
def _get_top_scopes(self, subscopes):
|
||||
"""Returns a list of highest level scopes
|
||||
(not a subscope of any other scopes)"""
|
||||
top_scopes = []
|
||||
for scope in self.scopes.keys():
|
||||
if scope not in subscopes:
|
||||
top_scopes.append(scope)
|
||||
return top_scopes
|
||||
|
||||
def _parse_scopes(self):
|
||||
"""Returns a list of table rows where row:
|
||||
[indented scopename string, scope description string]"""
|
||||
scope_pairs, subscopes = self._get_scope_relationships()
|
||||
top_scopes = self._get_top_scopes(subscopes)
|
||||
|
||||
table_rows = []
|
||||
md_indent = " "
|
||||
|
||||
def _add_subscopes(table_rows, scopename, depth=0):
|
||||
description = self.scopes[scopename]['description']
|
||||
doc_description = self.scopes[scopename].get('doc_description', '')
|
||||
if doc_description:
|
||||
description = doc_description
|
||||
table_row = [f"{md_indent * depth}`{scopename}`", description]
|
||||
table_rows.append(table_row)
|
||||
for subscope in scope_pairs[scopename]:
|
||||
if subscope:
|
||||
_add_subscopes(table_rows, subscope, depth + 1)
|
||||
|
||||
for scope in top_scopes:
|
||||
_add_subscopes(table_rows, scope)
|
||||
|
||||
return table_rows
|
||||
|
||||
def write_table(self):
|
||||
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
|
||||
filename = f"{HERE}/scope-table.md"
|
||||
table_name = ""
|
||||
headers = ["Scope", "Grants permission to:"]
|
||||
values = self._parse_scopes()
|
||||
writer = self.create_writer(table_name, headers, values)
|
||||
|
||||
title = "Table 1. Available scopes and their hierarchy"
|
||||
content = f"{title}\n{writer.dumps()}"
|
||||
with open(filename, 'w') as f:
|
||||
f.write(content)
|
||||
print(f"Generated {filename}.")
|
||||
print(
|
||||
"Run 'make clean' before 'make html' to ensure the built scopes.html contains latest scope table changes."
|
||||
)
|
||||
|
||||
def write_api(self):
|
||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
||||
filename = f"{PARENT}/rest-api.yml"
|
||||
yaml = YAML(typ='rt')
|
||||
yaml.preserve_quotes = True
|
||||
scope_dict = {}
|
||||
with open(filename, 'r+') as f:
|
||||
content = yaml.load(f.read())
|
||||
f.seek(0)
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
if doc_description:
|
||||
description = doc_description
|
||||
scope_dict[scope] = description
|
||||
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
|
||||
yaml.dump(content, f)
|
||||
f.truncate()
|
||||
|
||||
|
||||
def main():
|
||||
table_generator = ScopeTableGenerator()
|
||||
table_generator.write_table()
|
||||
table_generator.write_api()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
37
docs/source/rbac/index.md
Normal file
37
docs/source/rbac/index.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# JupyterHub RBAC
|
||||
|
||||
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
|
||||
|
||||
RBAC is new in JupyterHub 2.0.
|
||||
|
||||
## Motivation
|
||||
|
||||
The JupyterHub API requires authorization to access its APIs.
|
||||
This ensures that an arbitrary user, or even an unauthenticated third party, are not allowed to perform such actions.
|
||||
For instance, the behaviour prior to adoption of RBAC is that creating or deleting users requires _admin rights_.
|
||||
|
||||
The prior system is functional, but lacks flexibility. If your Hub serves a number of users in different groups, you might want to delegate permissions to other users or automate certain processes.
|
||||
Prior to RBAC, appointing a 'group-only admin' or a bot that culls idle servers, requires granting full admin rights to all actions. This poses a risk of the user or service intentionally or unintentionally accessing and modifying any data within the Hub and violates the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege).
|
||||
|
||||
To remedy situations like this, JupyterHub is transitioning to an RBAC system. By equipping users, groups and services with _roles_ that supply them with a collection of permissions (_scopes_), administrators are able to fine-tune which parties are granted access to which resources.
|
||||
|
||||
## Definitions
|
||||
|
||||
**Scopes** are specific permissions used to evaluate API requests. For example: the API endpoint `users/servers`, which enables starting or stopping user servers, is guarded by the scope `servers`.
|
||||
|
||||
Scopes are not directly assigned to requesters. Rather, when a client performs an API call, their access will be evaluated based on their assigned roles.
|
||||
|
||||
**Roles** are collections of scopes that specify the level of what a client is allowed to do. For example, a group administrator may be granted permission to control the servers of group members, but not to create, modify or delete group members themselves.
|
||||
Within the RBAC framework, this is achieved by assigning a role to the administrator that covers exactly those privileges.
|
||||
|
||||
## Technical Overview
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
roles
|
||||
scopes
|
||||
use-cases
|
||||
tech-implementation
|
||||
upgrade
|
||||
```
|
115
docs/source/rbac/roles.md
Normal file
115
docs/source/rbac/roles.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Roles
|
||||
|
||||
JupyterHub provides four roles that are available by default:
|
||||
|
||||
```{admonition} **Default roles**
|
||||
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
|
||||
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
|
||||
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
|
||||
- `server` role allows for posting activity of "itself" only.
|
||||
|
||||
**These roles cannot be deleted.**
|
||||
```
|
||||
|
||||
The `user`, `admin`, and `token` roles by default all preserve the permissions prior to RBAC.
|
||||
Only the `server` role is changed from pre-2.0, to reduce its permissions to activity-only
|
||||
instead of the default of a full access token.
|
||||
|
||||
Additional custom roles can also be defined (see {ref}`define-role-target`).
|
||||
Roles can be assigned to the following entities:
|
||||
|
||||
- Users
|
||||
- Services
|
||||
- Groups
|
||||
- Tokens
|
||||
|
||||
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
|
||||
|
||||
**Users** \
|
||||
When a new user gets created, they are assigned their default role `user`. Additionaly, if the user is created with admin privileges (via `c.Authenticator.admin_users` in `jupyterhub_config.py` or `admin: true` via API), they will be also granted `admin` role. If existing user's admin status changes via API or `jupyterhub_config.py`, their default role will be updated accordingly (after next startup for the latter).
|
||||
|
||||
**Services** \
|
||||
Services do not have a default role. Services without roles have no access to the guarded API end-points, so most services will require assignment of a role in order to function.
|
||||
|
||||
**Groups** \
|
||||
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
|
||||
|
||||
**Tokens** \
|
||||
A token’s permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role.
|
||||
|
||||
(define-role-target)=
|
||||
|
||||
## Defining Roles
|
||||
|
||||
Roles can be defined or modified in the configuration file as a list of dictionaries. An example:
|
||||
|
||||
% TODO: think about loading users into roles if membership has been changed via API.
|
||||
% What should be the result?
|
||||
|
||||
```python
|
||||
# in jupyterhub_config.py
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
'name': 'server-rights',
|
||||
'description': 'Allows parties to start and stop user servers',
|
||||
'scopes': ['servers'],
|
||||
'users': ['alice', 'bob'],
|
||||
'services': ['idle-culler'],
|
||||
'groups': ['admin-group'],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The role `server-rights` now allows the starting and stopping of servers by any of the following:
|
||||
|
||||
- users `alice` and `bob`
|
||||
- the service `idle-culler`
|
||||
- any member of the `admin-group`.
|
||||
|
||||
```{attention}
|
||||
Tokens cannot be assigned roles through role definition but may be assigned specific roles when requested via API (see {ref}`requesting-api-token-target`).
|
||||
```
|
||||
|
||||
Another example:
|
||||
|
||||
```python
|
||||
# in jupyterhub_config.py
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
'description': 'Read-only user models',
|
||||
'name': 'reader',
|
||||
'scopes': ['read:users'],
|
||||
'services': ['external'],
|
||||
'users': ['maria', 'joe']
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The role `reader` allows users `maria` and `joe` and service `external` to read (but not modify) any user’s model.
|
||||
|
||||
```{admonition} Requirements
|
||||
:class: warning
|
||||
In a role definition, the `name` field is required, while all other fields are optional.\
|
||||
**Role names must:**
|
||||
- be 3 - 255 characters
|
||||
- use ascii lowercase, numbers, 'unreserved' URL punctuation `-_.~`
|
||||
- start with a letter
|
||||
- end with letter or number.
|
||||
|
||||
`users`, `services`, and `groups` only accept objects that already exist in the database or are defined previously in the file.
|
||||
It is not possible to implicitly add a new user to the database by defining a new role.
|
||||
```
|
||||
|
||||
If no scopes are defined for _new role_, JupyterHub will raise a warning. Providing non-existing scopes will result in an error.
|
||||
|
||||
In case the role with a certain name already exists in the database, its definition and scopes will be overwritten. This holds true for all roles except the `admin` role, which cannot be overwritten; an error will be raised if trying to do so. All the role bearers permissions present in the definition will change accordingly.
|
||||
|
||||
(removing-roles-target)=
|
||||
|
||||
## Removing roles
|
||||
|
||||
Only the entities present in the role definition in the `jupyterhub_config.py` remain the role bearers. If a user, service or group is removed from the role definition, they will lose the role on the next startup.
|
||||
|
||||
Once a role is loaded, it remains in the database until removing it from the `jupyterhub_config.py` and restarting the Hub. All previously defined role bearers will lose the role and associated permissions. Default roles, even if previously redefined through the config file and removed, will not be deleted from the database.
|
122
docs/source/rbac/scopes.md
Normal file
122
docs/source/rbac/scopes.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Scopes in JupyterHub
|
||||
|
||||
A scope has a syntax-based design that reveals which resources it provides access to. Resources are objects with a type, associated data, relationships to other resources, and a set of methods that operate on them (see [RESTful API](https://restful-api-design.readthedocs.io/en/latest/resources.html) documentation for more information).
|
||||
|
||||
`<resource>` in the RBAC scope design refers to the resource name in the [JupyterHub's API](../reference/rest-api.rst) endpoints in most cases. For instance, `<resource>` equal to `users` corresponds to JupyterHub's API endpoints beginning with _/users_.
|
||||
|
||||
(scope-conventions-target)=
|
||||
|
||||
## Scope conventions
|
||||
|
||||
- `<resource>` \
|
||||
The top-level `<resource>` scopes, such as `users` or `groups`, grant read and write permissions to the resource itself as well as its sub-resources. For example, the scope `users:activity` is included in the scope `users`.
|
||||
|
||||
- `read:<resource>` \
|
||||
Limits permissions to read-only operations on the resource.
|
||||
|
||||
- `admin:<resource>` \
|
||||
Grants additional permissions such as create/delete on the corresponding resource in addition to read and write permissions.
|
||||
|
||||
- `access:<resource>` \
|
||||
Grants access permissions to the `<resource>` via API or browser.
|
||||
|
||||
- `<resource>:<subresource>` \
|
||||
The {ref}`vertically filtered <vertical-filtering-target>` scopes provide access to a subset of the information granted by the `<resource>` scope. E.g., the scope `users:activity` only provides permission to post user activity.
|
||||
|
||||
- `<resource>!<object>=<objectname>` \
|
||||
{ref}`horizontal-filtering-target` is implemented by the `!<object>=<objectname>`scope structure. A resource (or sub-resource) can be filtered based on `user`, `server`, `group` or `service` name. For instance, `<resource>!user=charlie` limits access to only return resources of user `charlie`. \
|
||||
Only one filter per scope is allowed, but filters for the same scope have an additive effect; a larger filter can be used by supplying the scope multiple times with different filters.
|
||||
|
||||
By adding a scope to an existing role, all role bearers will gain the associated permissions.
|
||||
|
||||
## Metascopes
|
||||
|
||||
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
|
||||
|
||||
1. default user scope `self`, and
|
||||
2. default token scope `all`.
|
||||
|
||||
(default-user-scope-target)=
|
||||
|
||||
### Default user scope
|
||||
|
||||
Access to the user's own resources and subresources is covered by metascope `self`. This metascope includes the user's model, activity, servers and tokens. For example, `self` for a user named "gerard" includes:
|
||||
|
||||
- `users!user=gerard` where the `users` scope provides access to the full user model and activity. The filter restricts this access to the user's own resources.
|
||||
- `servers!user=gerard` which grants the user access to their own servers without being able to create/delete any.
|
||||
- `tokens!user=gerard` which allows the user to access, request and delete their own tokens.
|
||||
- `access:servers!user=gerard` which allows the user to access their own servers via API or browser.
|
||||
|
||||
The `self` scope is only valid for user entities. In other cases (e.g., for services) it resolves to an empty set of scopes.
|
||||
|
||||
(default-token-scope-target)=
|
||||
|
||||
### Default token scope
|
||||
|
||||
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
|
||||
|
||||
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
||||
|
||||
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
|
||||
|
||||
(horizontal-filtering-target)=
|
||||
|
||||
## Horizontal filtering
|
||||
|
||||
Horizontal filtering, also called _resource filtering_, is the concept of reducing the payload of an API call to cover only the subset of the _resources_ that the scopes of the client provides them access to.
|
||||
Requested resources are filtered based on the filter of the corresponding scope. For instance, if a service requests a user list (guarded with scope `read:users`) with a role that only contains scopes `read:users!user=hannah` and `read:users!user=ivan`, the returned list of user models will be an intersection of all users and the collection `{hannah, ivan}`. In case this intersection is empty, the API call returns an HTTP 404 error, regardless if any users exist outside of the clients scope filter collection.
|
||||
|
||||
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
||||
|
||||
### `!user` filter
|
||||
|
||||
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
|
||||
|
||||
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
||||
|
||||
The filter can be applied to any scope.
|
||||
|
||||
(vertical-filtering-target)=
|
||||
|
||||
## Vertical filtering
|
||||
|
||||
Vertical filtering, also called _attribute filtering_, is the concept of reducing the payload of an API call to cover only the _attributes_ of the resources that the scopes of the client provides them access to. This occurs when the client scopes are subscopes of the API endpoint that is called.
|
||||
For instance, if a client requests a user list with the only scope being `read:users:groups`, the returned list of user models will contain only a list of groups per user.
|
||||
In case the client has multiple subscopes, the call returns the union of the data the client has access to.
|
||||
|
||||
The payload of an API call can be filtered both horizontally and vertically simultaneously. For instance, performing an API call to the endpoint `/users/` with the scope `users:name!user=juliette` returns a payload of `[{name: 'juliette'}]` (provided that this name is present in the database).
|
||||
|
||||
(available-scopes-target)=
|
||||
|
||||
## Available scopes
|
||||
|
||||
Table below lists all available scopes and illustrates their hierarchy. Indented scopes indicate subscopes of the scope(s) above them.
|
||||
|
||||
There are four exceptions to the general {ref}`scope conventions <scope-conventions-target>`:
|
||||
|
||||
- `read:users:name` is a subscope of both `read:users` and `read:servers`. \
|
||||
The `read:servers` scope requires access to the user name (server owner) due to named servers distinguished internally in the form `!server=username/servername`.
|
||||
|
||||
- `read:users:activity` is a subscope of both `read:users` and `users:activity`. \
|
||||
Posting activity via the `users:activity`, which is not included in `users` scope, needs to check the last valid activity of the user.
|
||||
|
||||
- `read:roles:users` is a subscope of both `read:roles` and `admin:users`. \
|
||||
Admin privileges to the _users_ resource include the information about user roles.
|
||||
|
||||
- `read:roles:groups` is a subscope of both `read:roles` and `admin:groups`. \
|
||||
Similar to the `read:roles:users` above.
|
||||
|
||||
```{include} scope-table.md
|
||||
|
||||
```
|
||||
|
||||
```{Caution}
|
||||
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
|
||||
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
||||
```
|
||||
|
||||
### Scopes and APIs
|
||||
|
||||
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
||||
|
||||
Listed scopes by each API endpoint reflect the "lowest" permissions required to gain any access to the corresponding API. For example, posting user's activity (_POST /users/:name/activity_) needs `users:activity` scope. If scope `users` is passed during the request, the access will be granted as the required scope is a subscope of the `users` scope. If, on the other hand, `read:users:activity` scope is passed, the access will be denied.
|
80
docs/source/rbac/tech-implementation.md
Normal file
80
docs/source/rbac/tech-implementation.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Technical Implementation
|
||||
|
||||
Roles are stored in the database, where they are associated with users, services, etc., and can be added or modified as explained in {ref}`define-role-target` section. Users, services, groups, and tokens can gain, change, and lose roles. This is currently achieved via `jupyterhub_config.py` (see {ref}`define-role-target`) and will be made available via API in future. The latter will allow for changing a token's role, and thereby its permissions, without the need to issue a new token.
|
||||
|
||||
Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. Scope variables take on five different formats which is reflected throughout the utilities via specific nomenclature:
|
||||
|
||||
```{admonition} **Scope variable nomenclature**
|
||||
:class: tip
|
||||
- _scopes_ \
|
||||
List of scopes with abbreviations (used in role definitions). E.g., `["users:activity!user"]`.
|
||||
- _expanded scopes_ \
|
||||
Set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
|
||||
- _parsed scopes_ \
|
||||
Dictionary JSON like format of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
|
||||
- _intersection_ \
|
||||
Set of expanded scopes as intersection of 2 expanded scope sets.
|
||||
- _identify scopes_ \
|
||||
Set of expanded scopes needed for identify (whoami) endpoints.
|
||||
```
|
||||
|
||||
(resolving-roles-scopes-target)=
|
||||
|
||||
## Resolving roles and scopes
|
||||
|
||||
**Resolving roles** refers to determining which roles a user, service, token, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
|
||||
|
||||
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
|
||||
|
||||
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific roles or making an API request. The following sections provide more details.
|
||||
|
||||
(requesting-api-token-target)=
|
||||
|
||||
### Requesting API token with specific roles
|
||||
|
||||
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific existing roles. To date, it is only possible to add roles to a token through the _POST /users/:name/tokens_ API where the roles can be specified in the token parameters body (see [](../reference/rest-api.rst)).
|
||||
|
||||
RBAC adds several steps into the token issue flow.
|
||||
|
||||
If no roles are requested, the token is issued with the default `token` role (providing the requester is allowed to create the token).
|
||||
|
||||
If the token is requested with any roles, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
|
||||
|
||||
If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed. The API request is resolved without additional errors using the scopes _intersection_, but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
|
||||
|
||||
Resolving a token's roles (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error.
|
||||
|
||||
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
|
||||
|
||||
```{figure} ../images/rbac-token-request-chart.png
|
||||
:align: center
|
||||
:name: token-request-chart
|
||||
|
||||
Figure 1. Resolving roles and scopes during API token request
|
||||
```
|
||||
|
||||
### Making an API request
|
||||
|
||||
With the RBAC framework each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
|
||||
|
||||
When an API request is performed, the requesting API token's roles are again resolved (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
|
||||
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
|
||||
|
||||
The passed scopes are compared to the scopes required to access the API as follows:
|
||||
|
||||
- if the API scopes are present within the set of passed scopes, the access is granted and the API returns its "full" response
|
||||
|
||||
- if that is not the case, another check is utilized to determine if subscopes of the required API scopes can be found in the passed scope set:
|
||||
|
||||
- if found, the RBAC framework employs the {ref}`filtering <vertical-filtering-target>` procedures to refine the API response to access only resource attributes corresponding to the passed scopes. For example, providing a scope `read:users:activity!group=class-C` for the _GET /users_ API will return a list of user models from group `class-C` containing only the `last_activity` attribute for each user model
|
||||
|
||||
- if not found, the access to API is denied
|
||||
|
||||
{ref}`Figure 2 <api-request-chart>` illustrates this process highlighting the steps where the role and scope resolutions as well as filtering occur in orange.
|
||||
|
||||
```{figure} ../images/rbac-api-request-chart.png
|
||||
:align: center
|
||||
:name: api-request-chart
|
||||
|
||||
Figure 2. Resolving roles and scopes when an API request is made
|
||||
```
|
54
docs/source/rbac/upgrade.md
Normal file
54
docs/source/rbac/upgrade.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Upgrading JupyterHub with RBAC framework
|
||||
|
||||
RBAC framework requires different database setup than any previous JupyterHub versions due to eliminating the distinction between OAuth and API tokens (see {ref}`oauth-vs-api-tokens-target` for more details). This requires merging the previously two different database tables into one. By doing so, all existing tokens created before the upgrade no longer comply with the new database version and must be replaced.
|
||||
|
||||
This is achieved by the Hub deleting all existing tokens during the database upgrade and recreating the tokens loaded via the `jupyterhub_config.py` file with updated structure. However, any manually issued or stored tokens are not recreated automatically and must be manually re-issued after the upgrade.
|
||||
|
||||
No other database records are affected.
|
||||
|
||||
(rbac-upgrade-steps-target)=
|
||||
|
||||
## Upgrade steps
|
||||
|
||||
1. All running **servers must be stopped** before proceeding with the upgrade.
|
||||
2. To upgrade the Hub, follow the [Upgrading JupyterHub](../admin/upgrading.rst) instructions.
|
||||
```{attention}
|
||||
We advise against defining any new roles in the `jupyterhub.config.py` file right after the upgrade is completed and JupyterHub restarted for the first time. This preserves the 'current' state of the Hub. You can define and assign new roles on any other following startup.
|
||||
```
|
||||
3. After restarting the Hub **re-issue all tokens that were previously issued manually** (i.e., not through the `jupyterhub_config.py` file).
|
||||
|
||||
When the JupyterHub is restarted for the first time after the upgrade, all users, services and tokens stored in the database or re-loaded through the configuration file will be assigned their default role. Any newly added entities after that will be assigned their default role only if no other specific role is requested for them.
|
||||
|
||||
## Changing the permissions after the upgrade
|
||||
|
||||
Once all the {ref}`upgrade steps <rbac-upgrade-steps-target>` above are completed, the RBAC framework will be available for utilization. You can define new roles, modify default roles (apart from `admin`) and assign them to entities as described in the {ref}`define-role-target` section.
|
||||
|
||||
We recommended the following procedure to start with RBAC:
|
||||
|
||||
1. Identify which admin users and services you would like to grant only the permissions they need through the new roles.
|
||||
2. Strip these users and services of their admin status via API or UI. This will change their roles from `admin` to `user`.
|
||||
```{note}
|
||||
Stripping entities of their roles is currently available only via `jupyterhub_config.py` (see {ref}`removing-roles-target`).
|
||||
```
|
||||
3. Define new roles that you would like to start using with appropriate scopes and assign them to these entities in `jupyterhub_config.py`.
|
||||
4. Restart the JupyterHub for the new roles to take effect.
|
||||
|
||||
(oauth-vs-api-tokens-target)=
|
||||
|
||||
## OAuth vs API tokens
|
||||
|
||||
### Before RBAC
|
||||
|
||||
Previous JupyterHub versions utilize two types of tokens, OAuth token and API token.
|
||||
|
||||
OAuth token is issued by the Hub to a single-user server when the user logs in. The token is stored in the browser cookie and is used to identify the user who owns the server during the OAuth flow. This token by default expires when the cookie reaches its expiry time of 2 weeks (or after 1 hour in JupyterHub versions < 1.3.0).
|
||||
|
||||
API token is issued by the Hub to a single-user server when launched and is used to communicate with the Hub's APIs such as posting activity or completing the OAuth flow. This token has no expiry by default.
|
||||
|
||||
API tokens can also be issued to users via API ([_/hub/token_](../reference/urls.md) or [_POST /users/:username/tokens_](../reference/rest-api.rst)) and services via `jupyterhub_config.py` to perform API requests.
|
||||
|
||||
### With RBAC
|
||||
|
||||
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
||||
|
||||
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.
|
130
docs/source/rbac/use-cases.md
Normal file
130
docs/source/rbac/use-cases.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Use Cases
|
||||
|
||||
To determine which scopes a role should have, one can follow these steps:
|
||||
|
||||
1. Determine what actions the role holder should have/have not access to
|
||||
2. Match the actions against the [JupyterHub's APIs](../reference/rest-api.rst)
|
||||
3. Check which scopes are required to access the APIs
|
||||
4. Combine scopes and subscopes if applicable
|
||||
5. Customize the scopes with filters if needed
|
||||
6. Define the role with required scopes and assign to users/services/groups/tokens
|
||||
|
||||
Below, different use cases are presented on how to use the RBAC framework.
|
||||
|
||||
## Service to cull idle servers
|
||||
|
||||
Finding and shutting down idle servers can save a lot of computational resources.
|
||||
We can make use of [jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler) to manage this for us.
|
||||
Below follows a short tutorial on how to add a cull-idle service in the RBAC system.
|
||||
|
||||
1. Install the cull-idle server script with `pip install jupyterhub-idle-culler`.
|
||||
2. Define a new service `idle-culler` and a new role for this service:
|
||||
|
||||
```python
|
||||
# in jupyterhub_config.py
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
"name": "idle-culler",
|
||||
"command": [
|
||||
sys.executable, "-m",
|
||||
"jupyterhub_idle_culler",
|
||||
"--timeout=3600"
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "idle-culler",
|
||||
"description": "Culls idle servers",
|
||||
"scopes": ["read:users:name", "read:users:activity", "servers"],
|
||||
"services": ["idle-culler"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```{important}
|
||||
Note that in the RBAC system the `admin` field in the `idle-culler` service definition is omitted. Instead, the `idle-culler` role provides the service with only the permissions it needs.
|
||||
|
||||
If the optional actions of deleting the idle servers and/or removing inactive users are desired, **change the following scopes** in the `idle-culler` role definition:
|
||||
- `servers` to `admin:servers` for deleting servers
|
||||
- `read:users:name`, `read:users:activity` to `admin:users` for deleting users.
|
||||
```
|
||||
|
||||
3. Restart JupyterHub to complete the process.
|
||||
|
||||
## API launcher
|
||||
|
||||
A service capable of creating/removing users and launching multiple servers should have access to:
|
||||
|
||||
1. _POST_ and _DELETE /users_
|
||||
2. _POST_ and _DELETE /users/:name/server_ or _/users/:name/servers/:server_name_
|
||||
3. Creating/deleting servers
|
||||
|
||||
The scopes required to access the API enpoints:
|
||||
|
||||
1. `admin:users`
|
||||
2. `servers`
|
||||
3. `admin:servers`
|
||||
|
||||
From the above, the role definition is:
|
||||
|
||||
```python
|
||||
# in jupyterhub_config.py
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "api-launcher",
|
||||
"description": "Manages servers",
|
||||
"scopes": ["admin:users", "admin:servers"],
|
||||
"services": [<service_name>]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
If needed, the scopes can be modified to limit the permissions to e.g. a particular group with `!group=groupname` filter.
|
||||
|
||||
## Group admin roles
|
||||
|
||||
Roles can be used to specify different group member privileges.
|
||||
|
||||
For example, a group of students `class-A` may have a role allowing all group members to access information about their group. Teacher `johan`, who is a student of `class-A` but a teacher of another group of students `class-B`, can have additional role permitting him to access information about `class-B` students as well as start/stop their servers.
|
||||
|
||||
The roles can then be defined as follows:
|
||||
|
||||
```python
|
||||
# in jupyterhub_config.py
|
||||
|
||||
c.JupyterHub.load_groups = {
|
||||
'class-A': ['johan', 'student1', 'student2'],
|
||||
'class-B': ['student3', 'student4']
|
||||
}
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
'name': 'class-A-student',
|
||||
'description': 'Grants access to information about the group',
|
||||
'scopes': ['read:groups!group=class-A'],
|
||||
'groups': ['class-A']
|
||||
},
|
||||
{
|
||||
'name': 'class-B-student',
|
||||
'description': 'Grants access to information about the group',
|
||||
'scopes': ['read:groups!group=class-B'],
|
||||
'groups': ['class-B']
|
||||
},
|
||||
{
|
||||
'name': 'teacher',
|
||||
'description': 'Allows for accessing information about teacher group members and starting/stopping their servers',
|
||||
'scopes': [ 'read:users!group=class-B', 'servers!group=class-B'],
|
||||
'users': ['johan']
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In the above example, `johan` has privileges inherited from `class-A-student` role and the `teacher` role on top of those.
|
||||
|
||||
```{note}
|
||||
The scope filters (`!group=`) limit the privileges only to the particular groups. `johan` can access the servers and information of `class-B` group members only.
|
||||
```
|
@@ -37,7 +37,7 @@ with any provider, is also available.
|
||||
## The Dummy Authenticator
|
||||
|
||||
When testing, it may be helpful to use the
|
||||
:class:`~jupyterhub.auth.DummyAuthenticator`. This allows for any username and
|
||||
{class}`jupyterhub.auth.DummyAuthenticator`. This allows for any username and
|
||||
password unless if a global password has been set. Once set, any username will
|
||||
still be accepted but the correct password will need to be provided.
|
||||
|
||||
|
@@ -26,3 +26,4 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
||||
config-proxy
|
||||
config-sudo
|
||||
config-reference
|
||||
oauth
|
||||
|
373
docs/source/reference/oauth.md
Normal file
373
docs/source/reference/oauth.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# JupyterHub and OAuth
|
||||
|
||||
JupyterHub uses OAuth 2 internally as a mechanism for authenticating users.
|
||||
As such, JupyterHub itself always functions as an OAuth **provider**.
|
||||
More on what that means [below](oauth-terms).
|
||||
|
||||
Additionally, JupyterHub is _often_ deployed with [oauthenticator](https://oauthenticator.readthedocs.io),
|
||||
where an external identity provider, such as GitHub or KeyCloak, is used to authenticate users.
|
||||
When this is the case, there are _two_ nested oauth flows:
|
||||
an _internal_ oauth flow where JupyterHub is the **provider**,
|
||||
and and _external_ oauth flow, where JupyterHub is a **client**.
|
||||
|
||||
This means that when you are using JupyterHub, there is always _at least one_ and often two layers of OAuth involved in a user logging in and accessing their server.
|
||||
|
||||
Some relevant points:
|
||||
|
||||
- Single-user servers _never_ need to communicate with or be aware of the upstream provider configured in your Authenticator.
|
||||
As far as they are concerned, only JupyterHub is an OAuth provider,
|
||||
and how users authenticate with the Hub itself is irrelevant.
|
||||
- When talking to a single-user server,
|
||||
there are ~always two tokens:
|
||||
a token issued to the server itself to communicate with the Hub API,
|
||||
and a second per-user token in the browser to represent the completed login process and authorized permissions.
|
||||
More on this [later](two-tokens).
|
||||
|
||||
(oauth-terms)=
|
||||
|
||||
## Key OAuth terms
|
||||
|
||||
Here are some key definitions to keep in mind when we are talking about OAuth.
|
||||
You can also read more detail [here](https://www.oauth.com/oauth2-servers/definitions/).
|
||||
|
||||
- **provider** the entity responsible for managing identity and authorization,
|
||||
always a web server.
|
||||
JupyterHub is _always_ an oauth provider for JupyterHub's components.
|
||||
When OAuthenticator is used, an external service, such as GitHub or KeyCloak, is also an oauth provider.
|
||||
- **client** An entity that requests OAuth **tokens** on a user's behalf,
|
||||
generally a web server of some kind.
|
||||
OAuth **clients** are services that _delegate_ authentication and/or authorization
|
||||
to an OAuth **provider**.
|
||||
JupyterHub _services_ or single-user _servers_ are OAuth **clients** of the JupyterHub **provider**.
|
||||
When OAuthenticator is used, JupyterHub is itself _also_ an OAuth **client** for the external oauth **provider**, e.g. GitHub.
|
||||
- **browser** A user's web browser, which makes requests and stores things like cookies
|
||||
- **token** The secret value used to represent a user's authorization. This is the final product of the OAuth process.
|
||||
- **code** A short-lived temporary secret that the **client** exchanges
|
||||
for a **token** at the conclusion of oauth,
|
||||
in what's generally called the "oauth callback handler."
|
||||
|
||||
## One oauth flow
|
||||
|
||||
OAuth **flow** is what we call the sequence of HTTP requests involved in authenticating a user and issuing a token, ultimately used for authorized access to a service or single-user server.
|
||||
|
||||
A single oauth flow generally goes like this:
|
||||
|
||||
### OAuth request and redirect
|
||||
|
||||
1. A **browser** makes an HTTP request to an oauth **client**.
|
||||
2. There are no credentials, so the client _redirects_ the browser to an "authorize" page on the oauth **provider** with some extra information:
|
||||
- the oauth **client id** of the client itself
|
||||
- the **redirect uri** to be redirected back to after completion
|
||||
- the **scopes** requested, which the user should be presented with to confirm.
|
||||
This is the "X would like to be able to Y on your behalf. Allow this?" page you see on all the "Login with ..." pages around the Internet.
|
||||
3. During this authorize step,
|
||||
the browser must be _authenticated_ with the provider.
|
||||
This is often already stored in a cookie,
|
||||
but if not the provider webapp must begin its _own_ authentication process before serving the authorization page.
|
||||
This _may_ even begin another oauth flow!
|
||||
4. After the user tells the provider that they want to proceed with the authorization,
|
||||
the provider records this authorization in a short-lived record called an **oauth code**.
|
||||
5. Finally, the oauth provider redirects the browser _back_ to the oauth client's "redirect uri"
|
||||
(or "oauth callback uri"),
|
||||
with the oauth code in a url parameter.
|
||||
|
||||
That's the end of the requests made between the **browser** and the **provider**.
|
||||
|
||||
### State after redirect
|
||||
|
||||
At this point:
|
||||
|
||||
- The browser is authenticated with the _provider_
|
||||
- The user's authorized permissions are recorded in an _oauth code_
|
||||
- The _provider_ knows that the given oauth client's requested permissions have been granted, but the client doesn't know this yet.
|
||||
- All requests so far have been made directly by the browser.
|
||||
No requests have originated at the client or provider.
|
||||
|
||||
### OAuth Client Handles Callback Request
|
||||
|
||||
Now we get to finish the OAuth process.
|
||||
Let's dig into what the oauth client does when it handles
|
||||
the oauth callback request with the
|
||||
|
||||
- The OAuth client receives the _code_ and makes an API request to the _provider_ to exchange the code for a real _token_.
|
||||
This is the first direct request between the OAuth _client_ and the _provider_.
|
||||
- Once the token is retrieved, the client _usually_
|
||||
makes a second API request to the _provider_
|
||||
to retrieve information about the owner of the token (the user).
|
||||
This is the step where behavior diverges for different OAuth providers.
|
||||
Up to this point, all oauth providers are the same, following the oauth specification.
|
||||
However, oauth does not define a standard for exchanging tokens for information about their owner or permissions ([OpenID Connect](https://openid.net/connect/) does that),
|
||||
so this step may be different for each OAuth provider.
|
||||
- Finally, the oauth client stores its own record that the user is authorized in a cookie.
|
||||
This could be the token itself, or any other appropriate representation of successful authentication.
|
||||
- Last of all, now that credentials have been established,
|
||||
the browser can be redirected to the _original_ URL where it started,
|
||||
to try the request again.
|
||||
If the client wasn't able to keep track of the original URL all this time
|
||||
(not always easy!),
|
||||
you might end up back at a default landing page instead of where you started the login process. This is frustrating!
|
||||
|
||||
😮💨 _phew_.
|
||||
|
||||
So that's _one_ OAuth process.
|
||||
|
||||
## Full sequence of OAuth in JupyterHub
|
||||
|
||||
Let's go through the above oauth process in JupyterHub,
|
||||
with specific examples of each HTTP request and what information is contained.
|
||||
For bonus points, we are using the double-oauth example of JupyterHub configured with GitHubOAuthenticator.
|
||||
|
||||
To disambiguate, we will call the OAuth process where JupyterHub is the **provider** "internal oauth,"
|
||||
and the one with JupyterHub as a **client** "external oauth."
|
||||
|
||||
Our starting point:
|
||||
|
||||
- a user's single-user server is running. Let's call them `danez`
|
||||
- jupyterhub is running with GitHub as an oauth provider (this means two full instances of oauth),
|
||||
- Danez has a fresh browser session with no cookies yet
|
||||
|
||||
First request:
|
||||
|
||||
- browser->single-user server running JupyterLab or Jupyter Classic
|
||||
- `GET /user/danez/notebooks/mynotebook.ipynb`
|
||||
- no credentials, so single-user server (as an oauth **client**) starts internal oauth process with JupyterHub (the **provider**)
|
||||
- response: 302 redirect -> `/hub/api/oauth2/authorize`
|
||||
with:
|
||||
- client-id=`jupyterhub-user-danez`
|
||||
- redirect-uri=`/user/danez/oauth_callback` (we'll come back later!)
|
||||
|
||||
Second request, following redirect:
|
||||
|
||||
- browser->jupyterhub
|
||||
- `GET /hub/api/oauth2/authorize`
|
||||
- no credentials, so jupyterhub starts external oauth process _with GitHub_
|
||||
- response: 302 redirect -> `https://github.com/login/oauth/authorize`
|
||||
with:
|
||||
- client-id=`jupyterhub-client-uuid`
|
||||
- redirect-uri=`/hub/oauth_callback` (we'll come back later!)
|
||||
|
||||
_pause_ This is where JupyterHub configuration comes into play.
|
||||
Recall, in this case JupyterHub is using:
|
||||
|
||||
```python
|
||||
c.JupyterHub.authenticator_class = 'github'
|
||||
```
|
||||
|
||||
That means authenticating a request to the Hub itself starts
|
||||
a _second_, external oauth process with GitHub as a provider.
|
||||
This external oauth process is optional, though.
|
||||
If you were using the default username+password PAMAuthenticator,
|
||||
this redirect would have been to `/hub/login` instead, to present the user
|
||||
with a login form.
|
||||
|
||||
Third request, following redirect:
|
||||
|
||||
- browser->GitHub
|
||||
- `GET https://github.com/login/oauth/authorize`
|
||||
|
||||
Here, GitHub prompts for login and asks for confirmation of authorization
|
||||
(more redirects if you aren't logged in to GitHub yet, but ultimately back to this `/authorize` URL).
|
||||
|
||||
After successful authorization
|
||||
(either by looking up a pre-existing authorization,
|
||||
or recording it via form submission)
|
||||
GitHub issues an **oauth code** and redirects to `/hub/oauth_callback?code=github-code`
|
||||
|
||||
Next request:
|
||||
|
||||
- browser->JupyterHub
|
||||
- `GET /hub/oauth_callback?code=github-code`
|
||||
|
||||
Inside the callback handler, JupyterHub makes two API requests:
|
||||
|
||||
The first:
|
||||
|
||||
- JupyterHub->GitHub
|
||||
- `POST https://github.com/login/oauth/access_token`
|
||||
- request made with oauth **code** from url parameter
|
||||
- response includes an access **token**
|
||||
|
||||
The second:
|
||||
|
||||
- JupyterHub->GitHub
|
||||
- `GET https://api.github.com/user`
|
||||
- request made with access **token** in the `Authorization` header
|
||||
- response is the user model, including username, email, etc.
|
||||
|
||||
Now the external oauth callback request completes with:
|
||||
|
||||
- set cookie on `/hub/` path, recording jupyterhub authentication so we don't need to do external oauth with GitHub again for a while
|
||||
- redirect -> `/hub/api/oauth2/authorize`
|
||||
|
||||
🎉 At this point, we have completed our first OAuth flow! 🎉
|
||||
|
||||
Now, we get our first repeated request:
|
||||
|
||||
- browser->jupyterhub
|
||||
- `GET /hub/api/oauth2/authorize`
|
||||
- this time with credentials,
|
||||
so jupyterhub either
|
||||
1. serves the internal authorization confirmation page, or
|
||||
2. automatically accepts authorization (shortcut taken when a user is visiting their own server)
|
||||
- redirect -> `/user/danez/oauth_callback?code=jupyterhub-code`
|
||||
|
||||
Here, we start the same oauth callback process as before, but at Danez's single-user server for the _internal_ oauth
|
||||
|
||||
- browser->single-user server
|
||||
- `GET /user/danez/oauth_callback`
|
||||
|
||||
(in handler)
|
||||
|
||||
Inside the internal oauth callback handler,
|
||||
Danez's server makes two API requests to JupyterHub:
|
||||
|
||||
The first:
|
||||
|
||||
- single-user server->JupyterHub
|
||||
- `POST /hub/api/oauth2/token`
|
||||
- request made with oauth code from url parameter
|
||||
- response includes an API token
|
||||
|
||||
The second:
|
||||
|
||||
- single-user server->JupyterHub
|
||||
- `GET /hub/api/user`
|
||||
- request made with token in the `Authorization` header
|
||||
- response is the user model, including username, groups, etc.
|
||||
|
||||
Finally completing `GET /user/danez/oauth_callback`:
|
||||
|
||||
- response sets cookie, storing encrypted access token
|
||||
- _finally_ redirects back to the original `/user/danez/notebooks/mynotebook.ipynb`
|
||||
|
||||
Final request:
|
||||
|
||||
- browser -> single-user server
|
||||
- `GET /user/danez/notebooks/mynotebook.ipynb`
|
||||
- encrypted jupyterhub token in cookie
|
||||
|
||||
To authenticate this request, the single token stored in the encrypted cookie is passed to the Hub for verification:
|
||||
|
||||
- single-user server -> Hub
|
||||
- `GET /hub/api/user`
|
||||
- browser's token in Authorization header
|
||||
- response: user model with name, groups, etc.
|
||||
|
||||
If the user model matches who should be allowed (e.g. Danez),
|
||||
then the request is allowed.
|
||||
See {doc}`../rbac/scopes` for how JupyterHub uses scopes to determine authorized access to servers and services.
|
||||
|
||||
_the end_
|
||||
|
||||
## Token caches and expiry
|
||||
|
||||
Because tokens represent information from an external source,
|
||||
they can become 'stale,'
|
||||
or the information they represent may no longer be accurate.
|
||||
For example: a user's GitHub account may no longer be authorized to use JupyterHub,
|
||||
that should ultimately propagate to revoking access and force logging in again.
|
||||
|
||||
To handle this, OAuth tokens and the various places they are stored can _expire_,
|
||||
which should have the same effect as no credentials,
|
||||
and trigger the authorization process again.
|
||||
|
||||
In JupyterHub's internal oauth, we have these layers of information that can go stale:
|
||||
|
||||
- The oauth client has a **cache** of Hub responses for tokens,
|
||||
so it doesn't need to make API requests to the Hub for every request it receives.
|
||||
This cache has an expiry of five minutes by default,
|
||||
and is governed by the configuration `HubAuth.cache_max_age` in the single-user server.
|
||||
- The internal oauth token is stored in a cookie, which has its own expiry (default: 14 days),
|
||||
governed by `JupyterHub.cookie_max_age_days`.
|
||||
- The internal oauth token can also itself expire,
|
||||
which is by default the same as the cookie expiry,
|
||||
since it makes sense for the token itself and the place it is stored to expire at the same time.
|
||||
This is governed by `JupyterHub.cookie_max_age_days` first,
|
||||
or can overridden by `JupyterHub.oauth_token_expires_in`.
|
||||
|
||||
That's all for _internal_ auth storage,
|
||||
but the information from the _external_ authentication provider
|
||||
(could be PAM or GitHub OAuth, etc.) can also expire.
|
||||
Authenticator configuration governs when JupyterHub needs to ask again,
|
||||
triggering the external login process anew before letting a user proceed.
|
||||
|
||||
- `jupyterhub-hub-login` cookie stores that a browser is authenticated with the Hub.
|
||||
This expires according to `JupyterHub.cookie_max_age_days` configuration,
|
||||
with a default of 14 days.
|
||||
The `jupyterhub-hub-login` cookie is encrypted with `JupyterHub.cookie_secret`
|
||||
configuration.
|
||||
- {meth}`.Authenticator.refresh_user` is a method to refresh a user's auth info.
|
||||
By default, it does nothing, but it can return an updated user model if a user's information has changed,
|
||||
or force a full login process again if needed.
|
||||
- {attr}`.Authenticator.auth_refresh_age` configuration governs how often
|
||||
`refresh_user()` will be called to check if a user must login again (default: 300 seconds).
|
||||
- {attr}`.Authenticator.refresh_pre_spawn` configuration governs whether
|
||||
`refresh_user()` should be called prior to spawning a server,
|
||||
to force fresh auth info when a server is launched (default: False).
|
||||
This can be useful when Authenticators pass access tokens to spawner environments, to ensure they aren't getting a stale token that's about to expire.
|
||||
|
||||
**So what happens when these things expire or get stale?**
|
||||
|
||||
- If the HubAuth **token response cache** expires,
|
||||
when a request is made with a token,
|
||||
the Hub is asked for the latest information about the token.
|
||||
This usually has no visible effect, since it is just refreshing a cache.
|
||||
If it turns out that the token itself has expired or been revoked,
|
||||
the request will be denied.
|
||||
- If the token has expired, but is still in the cookie:
|
||||
when the token response cache expires,
|
||||
the next time the server asks the hub about the token,
|
||||
no user will be identified and the internal oauth process begins again.
|
||||
- If the token _cookie_ expires, the next browser request will be made with no credentials,
|
||||
and the internal oauth process will begin again.
|
||||
This will usually have the form of a transparent redirect browsers won't notice.
|
||||
However, if this occurs on an API request in a long-lived page visit
|
||||
such as a JupyterLab session, the API request may fail and require
|
||||
a page refresh to get renewed credentials.
|
||||
- If the _JupyterHub_ cookie expires, the next time the browser makes a request to the Hub,
|
||||
the Hub's authorization process must begin again (e.g. login with GitHub).
|
||||
Hub cookie expiry on its own **does not** mean that a user can no longer access their single-user server!
|
||||
- If credentials from the upstream provider (e.g. GitHub) become stale or outdated,
|
||||
these will not be refreshed until/unless `refresh_user` is called
|
||||
_and_ `refresh_user()` on the given Authenticator is implemented to perform such a check.
|
||||
At this point, few Authenticators implement `refresh_user` to support this feature.
|
||||
If your Authenticator does not or cannot implement `refresh_user`,
|
||||
the only way to force a check is to reset the `JupyterHub.cookie_secret` encryption key,
|
||||
which invalidates the `jupyterhub-hub-login` cookie for all users.
|
||||
|
||||
### Logging out
|
||||
|
||||
Logging out of JupyterHub means clearing and revoking many of these credentials:
|
||||
|
||||
- The `jupyterhub-hub-login` cookie is revoked, meaning the next request to the Hub itself will require a new login.
|
||||
- The token stored in the `jupyterhub-user-username` cookie for the single-user server
|
||||
will be revoked, based on its associaton with `jupyterhub-session-id`, but the _cookie itself cannot be cleared at this point_
|
||||
- The shared `jupyterhub-session-id` is cleared, which ensures that the HubAuth **token response cache** will not be used,
|
||||
and the next request with the expired token will ask the Hub, which will inform the single-user server that the token has expired
|
||||
|
||||
## Extra bits
|
||||
|
||||
(two-tokens)=
|
||||
|
||||
### A tale of two tokens
|
||||
|
||||
**TODO**: discuss API token issued to server at startup ($JUPYTERHUB_API_TOKEN)
|
||||
and oauth-issued token in the cookie,
|
||||
and some details of how JupyterLab currently deals with that.
|
||||
They are different, and JupyterLab should be making requests using the token from the cookie,
|
||||
not the token from the server,
|
||||
but that is not currently the case.
|
||||
|
||||
### Redirect loops
|
||||
|
||||
In general, an authenticated web endpoint has this behavior,
|
||||
based on the authentication/authorization state of the browser:
|
||||
|
||||
- If authorized, allow the request to happen
|
||||
- If authenticated (I know who you are) but not authorized (you are not allowed), fail with a 403 permission denied error
|
||||
- If not authenticated, start a redirect process to establish authorization,
|
||||
which should end in a redirect back to the original URL to try again.
|
||||
**This is why problems in authentication result in redirect loops!**
|
||||
If the second request fails to detect the authentication that should have been established during the redirect,
|
||||
it will start the authentication redirect process over again,
|
||||
and keep redirecting in a loop until the browser balks.
|
@@ -86,10 +86,19 @@ Hub-Managed Service would include:
|
||||
This example would be configured as follows in `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "idle-culler",
|
||||
"scopes": [
|
||||
"read:users:activity", # read user last_activity
|
||||
"servers", # start and stop servers
|
||||
# 'admin:users' # needed if culling idle users as well
|
||||
]
|
||||
}
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'idle-culler',
|
||||
'admin': True,
|
||||
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
|
||||
}
|
||||
]
|
||||
@@ -114,6 +123,7 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||
Only for proxied web services.
|
||||
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
|
||||
```
|
||||
|
||||
For the previous 'cull idle' Service example, these environment variables
|
||||
@@ -203,8 +213,6 @@ To use HubAuth, you must set the `.api_token`, either programmatically when cons
|
||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
|
||||
Most of the logic for authentication implementation is found in the
|
||||
[`HubAuth.user_for_cookie`][hubauth.user_for_cookie]
|
||||
and in the
|
||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
||||
methods, which makes a request of the Hub, and returns:
|
||||
|
||||
@@ -233,53 +241,8 @@ service. See the `service-whoami-flask` example in the
|
||||
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
|
||||
for more details.
|
||||
|
||||
```python
|
||||
from functools import wraps
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, redirect, request, Response
|
||||
|
||||
from jupyterhub.services.auth import HubAuth
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubAuth(
|
||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||
cache_max_age=60,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
elif token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route(prefix)
|
||||
@authenticated
|
||||
def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True),
|
||||
mimetype='application/json',
|
||||
)
|
||||
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Authenticating tornado services with JupyterHub
|
||||
@@ -320,25 +283,38 @@ undefined, then any user will be allowed.
|
||||
If you don't want to use the reference implementation
|
||||
(e.g. you find the implementation a poor fit for your Flask app),
|
||||
you can implement authentication via the Hub yourself.
|
||||
We recommend looking at the [`HubAuth`][hubauth] class implementation for reference,
|
||||
JupyterHub is a standard OAuth2 provider,
|
||||
so you can use any OAuth 2 client implementation appropriate for your toolkit.
|
||||
See the [FastAPI example][] for an example of using JupyterHub as an OAuth provider with [FastAPI][],
|
||||
without using any code imported from JupyterHub.
|
||||
|
||||
On completion of OAuth, you will have an access token for JupyterHub,
|
||||
which can be used to identify the user and the permissions (scopes)
|
||||
the user has authorized for your service.
|
||||
|
||||
You will only get to this stage if the user has the required `access:services!service=$service-name` scope.
|
||||
|
||||
To retrieve the user model for the token, make a request to `GET /hub/api/user` with the token in the Authorization header.
|
||||
For example, using flask:
|
||||
|
||||
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
|
||||
:language: python
|
||||
```
|
||||
|
||||
We recommend looking at the [`HubOAuth`][huboauth] class implementation for reference,
|
||||
and taking note of the following process:
|
||||
|
||||
1. retrieve the cookie `jupyterhub-services` from the request.
|
||||
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
|
||||
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
|
||||
This request must be authenticated with a Hub API token in the `Authorization` header,
|
||||
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
|
||||
1. retrieve the token from the request.
|
||||
2. Make an API request `GET /hub/api/user`,
|
||||
with the token in the `Authorization` header.
|
||||
|
||||
For example, with [requests][]:
|
||||
|
||||
```python
|
||||
r = requests.get(
|
||||
'/'.join(["http://127.0.0.1:8081/hub/api",
|
||||
"authorizations/cookie/jupyterhub-services",
|
||||
quote(encrypted_cookie, safe=''),
|
||||
]),
|
||||
"http://127.0.0.1:8081/hub/api/user",
|
||||
headers = {
|
||||
'Authorization' : 'token %s' % api_token,
|
||||
'Authorization' : f'token {api_token}',
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
@@ -347,13 +323,27 @@ and taking note of the following process:
|
||||
|
||||
3. On success, the reply will be a JSON model describing the user:
|
||||
|
||||
```json
|
||||
```python
|
||||
{
|
||||
"name": "inara",
|
||||
"groups": ["serenity", "guild"]
|
||||
# groups may be omitted, depending on permissions
|
||||
"groups": ["serenity", "guild"],
|
||||
# scopes is new in JupyterHub 2.0
|
||||
"scopes": [
|
||||
"access:services",
|
||||
"read:users:name",
|
||||
"read:users!user=inara",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `scopes` field can be used to manage access.
|
||||
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
||||
Individual permissions may be revoked at any later point without revoking the token,
|
||||
in which case the `scopes` field in this model should be checked on each access.
|
||||
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
|
||||
|
||||
An example of using an Externally-Managed Service and authentication is
|
||||
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
|
||||
@@ -362,9 +352,10 @@ section on securing the notebook viewer.
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: ../api/services.auth.html
|
||||
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||
[hubauth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
|
||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||
[fastapi]: https://fastapi.tiangolo.com
|
||||
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
||||
|
@@ -10,7 +10,7 @@ from jupyter_client.localinterfaces import public_ips
|
||||
|
||||
|
||||
def create_dir_hook(spawner):
|
||||
""" Create directory """
|
||||
"""Create directory"""
|
||||
username = spawner.user.name # get the username
|
||||
volume_path = os.path.join('/volumes/jupyterhub', username)
|
||||
if not os.path.exists(volume_path):
|
||||
@@ -20,7 +20,7 @@ def create_dir_hook(spawner):
|
||||
|
||||
|
||||
def clean_dir_hook(spawner):
|
||||
""" Delete directory """
|
||||
"""Delete directory"""
|
||||
username = spawner.user.name # get the username
|
||||
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
|
||||
if os.path.exists(temp_path) and os.path.isdir(temp_path):
|
||||
|
@@ -13,7 +13,7 @@ if not api_token:
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'external-oauth',
|
||||
'oauth_client_id': "whoami-oauth-client-test",
|
||||
'oauth_client_id': "service-oauth-client-test",
|
||||
'api_token': api_token,
|
||||
'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback',
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ if [[ -z "${JUPYTERHUB_API_TOKEN}" ]]; then
|
||||
fi
|
||||
|
||||
# 2. oauth client ID
|
||||
export JUPYTERHUB_CLIENT_ID='whoami-oauth-client-test'
|
||||
export JUPYTERHUB_CLIENT_ID='service-oauth-client-test'
|
||||
# 3. where the Hub is
|
||||
export JUPYTERHUB_URL='http://127.0.0.1:8000'
|
||||
|
||||
|
@@ -9,7 +9,7 @@ if [[ -z "${JUPYTERHUB_API_TOKEN}" ]]; then
|
||||
fi
|
||||
|
||||
# 2. oauth client ID
|
||||
export JUPYTERHUB_CLIENT_ID="whoami-oauth-client-test"
|
||||
export JUPYTERHUB_CLIENT_ID="service-oauth-client-test"
|
||||
# 3. what URL to run on
|
||||
export JUPYTERHUB_SERVICE_PREFIX='/'
|
||||
export JUPYTERHUB_SERVICE_URL='http://127.0.0.1:5555'
|
||||
|
@@ -1,10 +1,10 @@
|
||||
# Configuration file for jupyterhub (postgres example).
|
||||
|
||||
c = get_config()
|
||||
c = get_config() # noqa
|
||||
|
||||
# Add some users.
|
||||
c.JupyterHub.admin_users = {'rhea'}
|
||||
c.Authenticator.whitelist = {'ganymede', 'io', 'rhea'}
|
||||
c.Authenticator.allowed_users = {'ganymede', 'io', 'rhea'}
|
||||
|
||||
# These environment variables are automatically supplied by the linked postgres
|
||||
# container.
|
||||
|
@@ -6,15 +6,17 @@ that appear when JupyterHub renders pages.
|
||||
To run the service as a hub-managed service simply include in your JupyterHub
|
||||
configuration file something like:
|
||||
|
||||
c.JupyterHub.services = [
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'announcement',
|
||||
'url': 'http://127.0.0.1:8888',
|
||||
'command': [sys.executable, "-m", "announcement"],
|
||||
'command': [sys.executable, "-m", "announcement", "--port", "8888"],
|
||||
}
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
This starts the announcements service up at `/services/announcement` when
|
||||
This starts the announcements service up at `/services/announcement/` when
|
||||
JupyterHub launches. By default the announcement text is empty.
|
||||
|
||||
The `announcement` module has a configurable port (default 8888) and an API
|
||||
@@ -23,15 +25,28 @@ that environment variable is set or `/` if it is not.
|
||||
|
||||
## Managing the Announcement
|
||||
|
||||
Admin users can set the announcement text with an API token:
|
||||
Users with permission can set the announcement text with an API token:
|
||||
|
||||
$ curl -X POST -H "Authorization: token <token>" \
|
||||
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
|
||||
https://.../services/announcement
|
||||
https://.../services/announcement/
|
||||
|
||||
To grant permission, add a role (JupyterHub 2.0) with access to the announcement service:
|
||||
|
||||
```python
|
||||
# grant the 'announcer' permission to access the announcement service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "announcers",
|
||||
"users": ["announcer"], # or groups
|
||||
"scopes": ["access:services!service=announcement"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Anyone can read the announcement:
|
||||
|
||||
$ curl https://.../services/announcement | python -m json.tool
|
||||
$ curl https://.../services/announcement/ | python -m json.tool
|
||||
{
|
||||
announcement: "JupyterHub will be upgraded on August 14!",
|
||||
timestamp: "...",
|
||||
@@ -41,10 +56,11 @@ Anyone can read the announcement:
|
||||
The time the announcement was posted is recorded in the `timestamp` field and
|
||||
the user who posted the announcement is recorded in the `user` field.
|
||||
|
||||
To clear the announcement text, just DELETE. Only admin users can do this.
|
||||
To clear the announcement text, send a DELETE request.
|
||||
This has the same permission requirement.
|
||||
|
||||
$ curl -X POST -H "Authorization: token <token>" \
|
||||
https://.../services/announcement
|
||||
$ curl -X DELETE -H "Authorization: token <token>" \
|
||||
https://.../services/announcement/
|
||||
|
||||
## Seeing the Announcement in JupyterHub
|
||||
|
||||
|
@@ -13,8 +13,6 @@ from jupyterhub.services.auth import HubAuthenticated
|
||||
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
|
||||
"""Dynamically manage page announcements"""
|
||||
|
||||
allow_admin = True
|
||||
|
||||
def initialize(self, storage):
|
||||
"""Create storage for announcement text"""
|
||||
self.storage = storage
|
||||
|
@@ -2,11 +2,18 @@ import sys
|
||||
|
||||
# To run the announcement service managed by the hub, add this.
|
||||
|
||||
port = 9999
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'announcement',
|
||||
'url': 'http://127.0.0.1:8888',
|
||||
'command': [sys.executable, "-m", "announcement"],
|
||||
'url': f'http://127.0.0.1:{port}',
|
||||
'command': [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"announcement",
|
||||
'--port',
|
||||
str(port),
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,3 +21,19 @@ c.JupyterHub.services = [
|
||||
# for an example of how to do this.
|
||||
|
||||
c.JupyterHub.template_paths = ["templates"]
|
||||
|
||||
c.Authenticator.allowed_users = {"announcer", "otheruser"}
|
||||
|
||||
# grant the 'announcer' permission to access the announcement service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "announcers",
|
||||
"users": ["announcer"],
|
||||
"scopes": ["access:services!service=announcement"],
|
||||
}
|
||||
]
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -16,6 +16,7 @@ jupyterhub --ip=127.0.0.1
|
||||
```
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
|
||||
Login with username 'test-user' and any password.
|
||||
|
||||
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
|
||||
|
||||
@@ -24,10 +25,10 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/
|
||||
{"Hello":"World"}
|
||||
|
||||
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
|
||||
{"detail":"Must login with token parameter, cookie, or header"}
|
||||
{"detail":"Must login with token parameter, or Authorization bearer header"}
|
||||
|
||||
$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \
|
||||
-d '{"username": "myname", "password": "mypasswd!"}' \
|
||||
$ curl -X POST http://127.0.0.1:8000/hub/api/users/test-user/tokens \
|
||||
-d '{"auth": {"username": "test-user", "password": "mypasswd!"}}' \
|
||||
| jq '.token'
|
||||
"3fee13ce6d2845da9bd5f2c2170d3428"
|
||||
|
||||
@@ -35,13 +36,18 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/me \
|
||||
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
|
||||
| jq .
|
||||
{
|
||||
"name": "myname",
|
||||
"name": "test-user",
|
||||
"admin": false,
|
||||
"groups": [],
|
||||
"server": null,
|
||||
"pending": null,
|
||||
"last_activity": "2021-04-07T18:05:11.587638+00:00",
|
||||
"servers": null
|
||||
"last_activity": "2021-05-21T09:13:00.514309+00:00",
|
||||
"servers": null,
|
||||
"scopes": [
|
||||
"access:services",
|
||||
"access:servers!user=test-user",
|
||||
"...",
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
@@ -22,11 +23,12 @@ class Server(BaseModel):
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
admin: bool
|
||||
groups: List[str]
|
||||
groups: Optional[List[str]]
|
||||
server: Optional[str]
|
||||
pending: Optional[str]
|
||||
last_activity: datetime
|
||||
servers: Optional[List[Server]]
|
||||
servers: Optional[Dict[str, Server]]
|
||||
scopes: List[str]
|
||||
|
||||
|
||||
# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -27,6 +28,12 @@ auth_by_header = OAuth2AuthorizationCodeBearer(
|
||||
### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an
|
||||
### access_token, which it returns to browser, which places in Authorization header.
|
||||
|
||||
if os.environ.get("JUPYTERHUB_OAUTH_SCOPES"):
|
||||
# typically ["access:services", "access:services!service=$service_name"]
|
||||
access_scopes = json.loads(os.environ["JUPYTERHUB_OAUTH_SCOPES"])
|
||||
else:
|
||||
access_scopes = ["access:services"]
|
||||
|
||||
### For consideration: optimize performance with a cache instead of
|
||||
### always hitting the Hub api?
|
||||
async def get_current_user(
|
||||
@@ -58,4 +65,15 @@ async def get_current_user(
|
||||
},
|
||||
)
|
||||
user = User(**resp.json())
|
||||
if any(scope in user.scopes for scope in access_scopes):
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"msg": f"User not authorized: {user.name}",
|
||||
"request_url": str(resp.request.url),
|
||||
"token": token,
|
||||
"user": resp.json(),
|
||||
},
|
||||
)
|
||||
|
@@ -24,8 +24,21 @@ c.JupyterHub.services = [
|
||||
"name": service_name,
|
||||
"url": "http://127.0.0.1:10202",
|
||||
"command": ["uvicorn", "app:app", "--port", "10202"],
|
||||
"admin": True,
|
||||
"oauth_redirect_uri": oauth_redirect_uri,
|
||||
"environment": {"PUBLIC_HOST": public_host},
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
# grant all users access to services
|
||||
"scopes": ["self", "access:services"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# dummy for testing, create test-user
|
||||
c.Authenticator.allowed_users = {"test-user"}
|
||||
c.JupyterHub.authenticator_class = "dummy"
|
||||
c.JupyterHub.spawner_class = "simple"
|
||||
|
@@ -1,15 +1,35 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
|
||||
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
service_name = 'shared-notebook'
|
||||
service_port = 9999
|
||||
group_name = 'shared'
|
||||
|
||||
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
|
||||
# ellisonbg and willingc are in a group that will access the shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'shared-notebook',
|
||||
'url': 'http://127.0.0.1:9999',
|
||||
'api_token': 'super-secret',
|
||||
'name': service_name,
|
||||
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
|
||||
}
|
||||
]
|
||||
|
||||
# This "role assignment" is what grants members of the group
|
||||
# access to the service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "shared-notebook",
|
||||
"groups": [group_name],
|
||||
"scopes": [f"access:services!service={service_name}"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -1,9 +1,11 @@
|
||||
#!/bin/bash -l
|
||||
set -e
|
||||
|
||||
export JUPYTERHUB_API_TOKEN=super-secret
|
||||
# these must match the values in jupyterhub_config.py
|
||||
export JUPYTERHUB_API_TOKEN=c3a29e5d386fd7c9aa1e8fe9d41c282ec8b
|
||||
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
|
||||
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
||||
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
|
||||
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
|
||||
|
||||
jupyterhub-singleuser \
|
||||
--group='shared'
|
||||
jupyterhub-singleuser
|
||||
|
@@ -1,19 +1,35 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
|
||||
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
service_name = 'shared-notebook'
|
||||
service_port = 9999
|
||||
group_name = 'shared'
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': service_name,
|
||||
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||
'command': ['jupyterhub-singleuser', '--group=shared', '--debug'],
|
||||
'command': ['jupyterhub-singleuser', '--debug'],
|
||||
}
|
||||
]
|
||||
|
||||
# This "role assignment" is what grants members of the group
|
||||
# access to the service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "shared-notebook",
|
||||
"groups": [group_name],
|
||||
"scopes": [f"access:services!service={service_name}"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Authenticating a flask service with JupyterHub
|
||||
|
||||
Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application.
|
||||
Uses `jupyterhub.services.HubOAuth` to authenticate requests with the Hub in a [flask][] application.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -8,7 +8,7 @@ Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [f
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami/ or http://127.0.0.1:8000/services/whoami-oauth/
|
||||
2. Visit http://127.0.0.1:8000/services/whoami/
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
|
@@ -5,10 +5,12 @@ c.JupyterHub.services = [
|
||||
'command': ['flask', 'run', '--port=10101'],
|
||||
'environment': {'FLASK_APP': 'whoami-flask.py'},
|
||||
},
|
||||
{
|
||||
'name': 'whoami-oauth',
|
||||
'url': 'http://127.0.0.1:10201',
|
||||
'command': ['flask', 'run', '--port=10201'],
|
||||
'environment': {'FLASK_APP': 'whoami-oauth.py'},
|
||||
},
|
||||
]
|
||||
|
||||
# dummy auth and simple spawner for testing
|
||||
# any username and password will work
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
|
||||
# listen only on localhost while testing with wide-open auth
|
||||
c.JupyterHub.ip = '127.0.0.1'
|
||||
|
@@ -4,42 +4,48 @@ whoami service authentication with the Hub
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from functools import wraps
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask
|
||||
from flask import make_response
|
||||
from flask import redirect
|
||||
from flask import request
|
||||
from flask import Response
|
||||
from flask import session
|
||||
|
||||
from jupyterhub.services.auth import HubAuth
|
||||
from jupyterhub.services.auth import HubOAuth
|
||||
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
||||
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
||||
|
||||
app = Flask(__name__)
|
||||
# encryption key for session cookies
|
||||
app.secret_key = secrets.token_bytes(32)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub"""
|
||||
"""Decorator for authenticating with the Hub via OAuth"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
cookie = request.cookies.get(auth.cookie_name)
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if cookie:
|
||||
user = auth.user_for_cookie(cookie)
|
||||
elif token:
|
||||
token = session.get("token")
|
||||
|
||||
if token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
||||
state = auth.generate_state(next_url=request.path)
|
||||
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
||||
response.set_cookie(auth.state_cookie_name, state)
|
||||
return response
|
||||
|
||||
return decorated
|
||||
|
||||
@@ -50,3 +56,24 @@ def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
||||
)
|
||||
|
||||
|
||||
@app.route(prefix + 'oauth_callback')
|
||||
def oauth_callback():
|
||||
code = request.args.get('code', None)
|
||||
if code is None:
|
||||
return 403
|
||||
|
||||
# validate state field
|
||||
arg_state = request.args.get('state', None)
|
||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||
if arg_state is None or arg_state != cookie_state:
|
||||
# state doesn't match
|
||||
return 403
|
||||
|
||||
token = auth.token_for_code(code)
|
||||
# store token in session cookie
|
||||
session["token"] = token
|
||||
next_url = auth.get_next_url(cookie_state) or prefix
|
||||
response = make_response(redirect(next_url))
|
||||
return response
|
||||
|
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
whoami service authentication with the Hub
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask
|
||||
from flask import make_response
|
||||
from flask import redirect
|
||||
from flask import request
|
||||
from flask import Response
|
||||
|
||||
from jupyterhub.services.auth import HubOAuth
|
||||
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub via OAuth"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = request.cookies.get(auth.cookie_name)
|
||||
if token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
state = auth.generate_state(next_url=request.path)
|
||||
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
||||
response.set_cookie(auth.state_cookie_name, state)
|
||||
return response
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route(prefix)
|
||||
@authenticated
|
||||
def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
||||
)
|
||||
|
||||
|
||||
@app.route(prefix + 'oauth_callback')
|
||||
def oauth_callback():
|
||||
code = request.args.get('code', None)
|
||||
if code is None:
|
||||
return 403
|
||||
|
||||
# validate state field
|
||||
arg_state = request.args.get('state', None)
|
||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||
if arg_state is None or arg_state != cookie_state:
|
||||
# state doesn't match
|
||||
return 403
|
||||
|
||||
token = auth.token_for_code(code)
|
||||
next_url = auth.get_next_url(cookie_state) or prefix
|
||||
response = make_response(redirect(next_url))
|
||||
response.set_cookie(auth.cookie_name, token)
|
||||
return response
|
@@ -2,15 +2,15 @@
|
||||
|
||||
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
|
||||
|
||||
There is an implementation each of cookie-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
||||
There is an implementation each of api-token-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
||||
|
||||
## Run
|
||||
|
||||
1. Launch JupyterHub and the `whoami service` with
|
||||
1. Launch JupyterHub and the `whoami` services with
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
|
||||
2. Visit http://127.0.0.1:8000/services/whoami-oauth
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
@@ -24,15 +24,65 @@ After logging in with your local-system credentials, you should see a JSON dump
|
||||
}
|
||||
```
|
||||
|
||||
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
|
||||
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
|
||||
and making a direct request:
|
||||
|
||||
```bash
|
||||
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
||||
{
|
||||
"admin": false,
|
||||
"created": "2021-05-21T09:47:41.299400Z",
|
||||
"groups": [],
|
||||
"kind": "user",
|
||||
"last_activity": "2021-05-21T09:49:08.290745Z",
|
||||
"name": "test",
|
||||
"pending": null,
|
||||
"roles": [
|
||||
"user"
|
||||
],
|
||||
"scopes": [
|
||||
"access:services",
|
||||
"access:servers!user=test",
|
||||
"read:users!user=test",
|
||||
"read:users:activity!user=test",
|
||||
"read:users:groups!user=test",
|
||||
"read:users:name!user=test",
|
||||
"read:servers!user=test",
|
||||
"read:tokens!user=test",
|
||||
"users!user=test",
|
||||
"users:activity!user=test",
|
||||
"users:groups!user=test",
|
||||
"users:name!user=test",
|
||||
"servers!user=test",
|
||||
"tokens!user=test"
|
||||
],
|
||||
"server": null
|
||||
}
|
||||
```
|
||||
|
||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||
|
||||
You may set the `hub_users` configuration in the service script
|
||||
to restrict access to the service to a whitelist of allowed users.
|
||||
By default, any authenticated user is allowed.
|
||||
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
|
||||
and assign users to the scope.
|
||||
|
||||
The jupyterhub_config.py grants access for all users to all services via the default 'user' role, with:
|
||||
|
||||
```python
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
# grant all users access to all services
|
||||
"scopes": ["access:services", "self"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
||||
|
||||
JUPYTERHUB_API_TOKEN
|
||||
JUPYTERHUB_SERVICE_PREFIX
|
||||
JUPYTERHUB_OAUTH_SCOPES
|
||||
JUPYTERHUB_CLIENT_ID # for whoami-oauth only
|
||||
|
||||
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.
|
||||
|
@@ -2,7 +2,7 @@ import sys
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'whoami',
|
||||
'name': 'whoami-api',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'command': [sys.executable, './whoami.py'],
|
||||
},
|
||||
@@ -10,5 +10,19 @@ c.JupyterHub.services = [
|
||||
'name': 'whoami-oauth',
|
||||
'url': 'http://127.0.0.1:10102',
|
||||
'command': [sys.executable, './whoami-oauth.py'],
|
||||
'oauth_roles': ['user'],
|
||||
},
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
# grant all users access to all services
|
||||
"scopes": ["access:services", "self"],
|
||||
}
|
||||
]
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""An example service authenticating with the Hub.
|
||||
|
||||
This example service serves `/services/whoami/`,
|
||||
This example service serves `/services/whoami-oauth/`,
|
||||
authenticated with the Hub,
|
||||
showing the user their own info.
|
||||
"""
|
||||
@@ -20,13 +20,6 @@ from jupyterhub.utils import url_path_join
|
||||
|
||||
|
||||
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
||||
# hub_users can be a set of users who are allowed to access the service
|
||||
# `getuser()` here would mean only the user who started the service
|
||||
# can access the service:
|
||||
|
||||
# from getpass import getuser
|
||||
# hub_users = {getuser()}
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
user_model = self.get_current_user()
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"""An example service authenticating with the Hub.
|
||||
|
||||
This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info.
|
||||
This serves `/services/whoami-api/`, authenticated with the Hub, showing the user their own info.
|
||||
|
||||
HubAuthenticated only supports token-based access.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -16,13 +18,6 @@ from jupyterhub.services.auth import HubAuthenticated
|
||||
|
||||
|
||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||
# hub_users can be a set of users who are allowed to access the service
|
||||
# `getuser()` here would mean only the user who started the service
|
||||
# can access the service:
|
||||
|
||||
# from getpass import getuser
|
||||
# hub_users = {getuser()}
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
user_model = self.get_current_user()
|
||||
|
@@ -7,10 +7,9 @@ const withAPI = withProps(() => ({
|
||||
data.json()
|
||||
),
|
||||
updateGroups: (offset, limit) =>
|
||||
jhapiRequest(
|
||||
`/groups?offset=${offset}&limit=${limit}`,
|
||||
"GET"
|
||||
).then((data) => data.json()),
|
||||
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
||||
(data) => data.json()
|
||||
),
|
||||
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
||||
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
|
||||
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
|
||||
|
@@ -3,8 +3,8 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
version_info = (
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
"", # release (b1, rc1, or "" for final or dev)
|
||||
"dev", # dev or nothing for beta/rc/stable releases
|
||||
|
104
jupyterhub/alembic/versions/833da8570507_rbac.py
Normal file
104
jupyterhub/alembic/versions/833da8570507_rbac.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
rbac changes for jupyterhub 2.0
|
||||
|
||||
Revision ID: 833da8570507
|
||||
Revises: 4dc2d5a8c53c
|
||||
Create Date: 2021-02-17 15:03:04.360368
|
||||
|
||||
"""
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '833da8570507'
|
||||
down_revision = '4dc2d5a8c53c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from jupyterhub import orm
|
||||
|
||||
|
||||
naming_convention = orm.meta.naming_convention
|
||||
|
||||
|
||||
def upgrade():
|
||||
# associate spawners and services with their oauth clients
|
||||
# op.add_column(
|
||||
# 'services', sa.Column('oauth_client_id', sa.Unicode(length=255), nullable=True)
|
||||
# )
|
||||
for table_name in ('services', 'spawners'):
|
||||
column_name = "oauth_client_id"
|
||||
target_table = "oauth_clients"
|
||||
target_column = "identifier"
|
||||
with op.batch_alter_table(
|
||||
table_name,
|
||||
schema=None,
|
||||
) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('oauth_client_id', sa.Unicode(length=255), nullable=True),
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
naming_convention["fk"]
|
||||
% dict(
|
||||
table_name=table_name,
|
||||
column_0_name=column_name,
|
||||
referred_table_name=target_table,
|
||||
),
|
||||
target_table,
|
||||
[column_name],
|
||||
[target_column],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
|
||||
# FIXME, maybe: currently drops all api tokens and forces recreation!
|
||||
# this ensures a consistent database, but requires:
|
||||
# 1. all servers to be stopped for upgrade (maybe unavoidable anyway)
|
||||
# 2. any manually issued/stored tokens to be re-issued
|
||||
|
||||
# tokens loaded via configuration will be recreated on launch and unaffected
|
||||
op.drop_table('api_tokens')
|
||||
op.drop_table('oauth_access_tokens')
|
||||
return
|
||||
# TODO: explore in-place migration. This seems hard!
|
||||
# 1. add new columns in api tokens
|
||||
# 2. fill default fields (client_id='jupyterhub') for all api tokens
|
||||
# 3. copy oauth tokens into api tokens
|
||||
# 4. give oauth tokens 'identify' scopes
|
||||
|
||||
|
||||
def downgrade():
|
||||
for table_name in ('services', 'spawners'):
|
||||
column_name = "oauth_client_id"
|
||||
target_table = "oauth_clients"
|
||||
target_column = "identifier"
|
||||
|
||||
with op.batch_alter_table(
|
||||
table_name,
|
||||
schema=None,
|
||||
naming_convention=orm.meta.naming_convention,
|
||||
) as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
naming_convention["fk"]
|
||||
% dict(
|
||||
table_name=table_name,
|
||||
column_0_name=column_name,
|
||||
referred_table_name=target_table,
|
||||
),
|
||||
type_='foreignkey',
|
||||
)
|
||||
batch_op.drop_column(column_name)
|
||||
|
||||
# delete OAuth tokens for non-jupyterhub clients
|
||||
# drop new columns from api tokens
|
||||
# op.drop_constraint(None, 'api_tokens', type_='foreignkey')
|
||||
# op.drop_column('api_tokens', 'session_id')
|
||||
# op.drop_column('api_tokens', 'client_id')
|
||||
|
||||
# FIXME: only drop tokens whose client id is not 'jupyterhub'
|
||||
# until then, drop all tokens
|
||||
op.drop_table("api_tokens")
|
||||
|
||||
op.drop_table('api_token_role_map')
|
||||
op.drop_table('service_role_map')
|
||||
op.drop_table('user_role_map')
|
||||
op.drop_table('roles')
|
@@ -1,6 +1,7 @@
|
||||
"""Authorization handlers"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import itertools
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qsl
|
||||
@@ -13,8 +14,8 @@ from oauthlib import oauth2
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..utils import compare_token
|
||||
from .. import roles
|
||||
from .. import scopes
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
from .base import BaseHandler
|
||||
@@ -23,12 +24,22 @@ from .base import BaseHandler
|
||||
class TokenAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, token):
|
||||
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
||||
# TODO: require specific scope for this deprecated API, applied to service tokens only?
|
||||
self.log.warning(
|
||||
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
|
||||
)
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
orm_token = orm.OAuthAccessToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
raise web.HTTPError(404)
|
||||
|
||||
owner = orm_token.user or orm_token.service
|
||||
if owner:
|
||||
# having a token means we should be able to read the owner's model
|
||||
# (this is the only thing this handler is for)
|
||||
self.expanded_scopes.update(scopes.identify_scopes(owner))
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
|
||||
# record activity whenever we see a token
|
||||
now = orm_token.last_activity = datetime.utcnow()
|
||||
if orm_token.user:
|
||||
@@ -45,53 +56,20 @@ class TokenAPIHandler(APIHandler):
|
||||
self.write(json.dumps(model))
|
||||
|
||||
async def post(self):
|
||||
warn_msg = (
|
||||
"Using deprecated token creation endpoint %s."
|
||||
" Use /hub/api/users/:user/tokens instead."
|
||||
) % self.request.uri
|
||||
self.log.warning(warn_msg)
|
||||
requester = user = self.current_user
|
||||
if user is None:
|
||||
# allow requesting a token with username and password
|
||||
# for authenticators where that's possible
|
||||
data = self.get_json_body()
|
||||
try:
|
||||
requester = user = await self.login_user(data)
|
||||
except Exception as e:
|
||||
self.log.error("Failure trying to authenticate with form data: %s" % e)
|
||||
user = None
|
||||
if user is None:
|
||||
raise web.HTTPError(403)
|
||||
else:
|
||||
data = self.get_json_body()
|
||||
# admin users can request tokens for other users
|
||||
if data and data.get('username'):
|
||||
user = self.find_user(data['username'])
|
||||
if user is not requester and not requester.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can request tokens for other users."
|
||||
)
|
||||
if requester.admin and user is None:
|
||||
raise web.HTTPError(400, "No such user '%s'" % data['username'])
|
||||
|
||||
note = (data or {}).get('note')
|
||||
if not note:
|
||||
note = "Requested via deprecated api"
|
||||
if requester is not user:
|
||||
kind = 'user' if isinstance(user, User) else 'service'
|
||||
note += " by %s %s" % (kind, requester.name)
|
||||
|
||||
api_token = user.new_api_token(note=note)
|
||||
self.write(
|
||||
json.dumps(
|
||||
{'token': api_token, 'warning': warn_msg, 'user': self.user_model(user)}
|
||||
)
|
||||
404,
|
||||
"Deprecated endpoint /hub/api/authorizations/token is removed in JupyterHub 2.0."
|
||||
" Use /hub/api/users/:user/tokens instead.",
|
||||
)
|
||||
|
||||
|
||||
class CookieAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, cookie_name, cookie_value=None):
|
||||
self.log.warning(
|
||||
"/authorizations/cookie endpoint is deprecated in JupyterHub 2.0. Use /api/user with OAuth tokens."
|
||||
)
|
||||
|
||||
cookie_name = quote(cookie_name, safe='')
|
||||
if cookie_value is None:
|
||||
self.log.warning(
|
||||
@@ -198,12 +176,16 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
raise
|
||||
self.send_oauth_response(headers, body, status)
|
||||
|
||||
def needs_oauth_confirm(self, user, oauth_client):
|
||||
def needs_oauth_confirm(self, user, oauth_client, roles):
|
||||
"""Return whether the given oauth client needs to prompt for access for the given user
|
||||
|
||||
Checks list for oauth clients that don't need confirmation
|
||||
|
||||
(i.e. the user's own server)
|
||||
Sources:
|
||||
|
||||
- the user's own servers
|
||||
- Clients which already have authorization for the same roles
|
||||
- Explicit oauth_no_confirm_list configuration (e.g. admin-operated services)
|
||||
|
||||
.. versionadded: 1.1
|
||||
"""
|
||||
@@ -219,6 +201,27 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
in self.settings.get('oauth_no_confirm_list', set())
|
||||
):
|
||||
return False
|
||||
|
||||
# Check existing authorization
|
||||
existing_tokens = self.db.query(orm.APIToken).filter_by(
|
||||
user_id=user.id,
|
||||
client_id=oauth_client.identifier,
|
||||
)
|
||||
authorized_roles = set()
|
||||
for token in existing_tokens:
|
||||
authorized_roles.update({role.name for role in token.roles})
|
||||
|
||||
if authorized_roles:
|
||||
if set(roles).issubset(authorized_roles):
|
||||
self.log.debug(
|
||||
f"User {user.name} has already authorized {oauth_client.identifier} for roles {roles}"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self.log.debug(
|
||||
f"User {user.name} has authorized {oauth_client.identifier}"
|
||||
f" for roles {authorized_roles}, confirming additonal roles {roles}"
|
||||
)
|
||||
# default: require confirmation
|
||||
return True
|
||||
|
||||
@@ -243,28 +246,90 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
try:
|
||||
scopes, credentials = self.oauth_provider.validate_authorization_request(
|
||||
(
|
||||
role_names,
|
||||
credentials,
|
||||
) = self.oauth_provider.validate_authorization_request(
|
||||
uri, http_method, body, headers
|
||||
)
|
||||
credentials = self.add_credentials(credentials)
|
||||
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
|
||||
if not self.needs_oauth_confirm(self.current_user, client):
|
||||
allowed = False
|
||||
|
||||
# check for access to target resource
|
||||
if client.spawner:
|
||||
scope_filter = self.get_scope_filter("access:servers")
|
||||
allowed = scope_filter(client.spawner, kind='server')
|
||||
elif client.service:
|
||||
scope_filter = self.get_scope_filter("access:services")
|
||||
allowed = scope_filter(client.service, kind='service')
|
||||
else:
|
||||
# client is not associated with a service or spawner.
|
||||
# This shouldn't happen, but it might if this is a stale or forged request
|
||||
# from a service or spawner that's since been deleted
|
||||
self.log.error(
|
||||
f"OAuth client {client} has no service or spawner, cannot resolve scopes."
|
||||
)
|
||||
raise web.HTTPError(500, "OAuth configuration error")
|
||||
|
||||
if not allowed:
|
||||
self.log.error(
|
||||
f"User {self.current_user} not allowed to access {client.description}"
|
||||
)
|
||||
raise web.HTTPError(
|
||||
403, f"You do not have permission to access {client.description}"
|
||||
)
|
||||
if not self.needs_oauth_confirm(self.current_user, client, role_names):
|
||||
self.log.debug(
|
||||
"Skipping oauth confirmation for %s accessing %s",
|
||||
self.current_user,
|
||||
client.description,
|
||||
)
|
||||
# this is the pre-1.0 behavior for all oauth
|
||||
self._complete_login(uri, headers, scopes, credentials)
|
||||
self._complete_login(uri, headers, role_names, credentials)
|
||||
return
|
||||
|
||||
# resolve roles to scopes for authorization page
|
||||
raw_scopes = set()
|
||||
if role_names:
|
||||
role_objects = (
|
||||
self.db.query(orm.Role).filter(orm.Role.name.in_(role_names)).all()
|
||||
)
|
||||
raw_scopes = set(
|
||||
itertools.chain(*(role.scopes for role in role_objects))
|
||||
)
|
||||
if not raw_scopes:
|
||||
scope_descriptions = [
|
||||
{
|
||||
"scope": None,
|
||||
"description": scopes.scope_definitions['(no_scope)'][
|
||||
'description'
|
||||
],
|
||||
"filter": "",
|
||||
}
|
||||
]
|
||||
elif 'all' in raw_scopes:
|
||||
raw_scopes = ['all']
|
||||
scope_descriptions = [
|
||||
{
|
||||
"scope": "all",
|
||||
"description": scopes.scope_definitions['all']['description'],
|
||||
"filter": "",
|
||||
}
|
||||
]
|
||||
else:
|
||||
scope_descriptions = scopes.describe_raw_scopes(
|
||||
raw_scopes,
|
||||
username=self.current_user.name,
|
||||
)
|
||||
# Render oauth 'Authorize application...' page
|
||||
auth_state = await self.current_user.get_auth_state()
|
||||
self.write(
|
||||
await self.render_template(
|
||||
"oauth.html",
|
||||
auth_state=auth_state,
|
||||
scopes=scopes,
|
||||
role_names=role_names,
|
||||
scope_descriptions=scope_descriptions,
|
||||
oauth_client=client,
|
||||
)
|
||||
)
|
||||
|
@@ -2,7 +2,6 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
from datetime import datetime
|
||||
from http.client import responses
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -131,36 +130,26 @@ class APIHandler(BaseHandler):
|
||||
json.dumps({'status': status_code, 'message': message or status_message})
|
||||
)
|
||||
|
||||
def server_model(self, spawner, include_state=False):
|
||||
"""Get the JSON model for a Spawner"""
|
||||
return {
|
||||
def server_model(self, spawner):
|
||||
"""Get the JSON model for a Spawner
|
||||
Assume server permission already granted"""
|
||||
model = {
|
||||
'name': spawner.name,
|
||||
'last_activity': isoformat(spawner.orm_spawner.last_activity),
|
||||
'started': isoformat(spawner.orm_spawner.started),
|
||||
'pending': spawner.pending,
|
||||
'ready': spawner.ready,
|
||||
'state': spawner.get_state() if include_state else None,
|
||||
'url': url_path_join(spawner.user.url, spawner.name, '/'),
|
||||
'user_options': spawner.user_options,
|
||||
'progress_url': spawner._progress_url,
|
||||
}
|
||||
scope_filter = self.get_scope_filter('admin:server_state')
|
||||
if scope_filter(spawner, kind='server'):
|
||||
model['state'] = spawner.get_state()
|
||||
return model
|
||||
|
||||
def token_model(self, token):
|
||||
"""Get the JSON model for an APIToken"""
|
||||
expires_at = None
|
||||
if isinstance(token, orm.APIToken):
|
||||
kind = 'api_token'
|
||||
extra = {'note': token.note}
|
||||
expires_at = token.expires_at
|
||||
elif isinstance(token, orm.OAuthAccessToken):
|
||||
kind = 'oauth'
|
||||
extra = {'oauth_client': token.client.description or token.client.client_id}
|
||||
if token.expires_at:
|
||||
expires_at = datetime.fromtimestamp(token.expires_at)
|
||||
else:
|
||||
raise TypeError(
|
||||
"token must be an APIToken or OAuthAccessToken, not %s" % type(token)
|
||||
)
|
||||
|
||||
if token.user:
|
||||
owner_key = 'user'
|
||||
@@ -173,59 +162,148 @@ class APIHandler(BaseHandler):
|
||||
model = {
|
||||
owner_key: owner,
|
||||
'id': token.api_id,
|
||||
'kind': kind,
|
||||
'kind': 'api_token',
|
||||
'roles': [r.name for r in token.roles],
|
||||
'created': isoformat(token.created),
|
||||
'last_activity': isoformat(token.last_activity),
|
||||
'expires_at': isoformat(expires_at),
|
||||
'expires_at': isoformat(token.expires_at),
|
||||
'note': token.note,
|
||||
'oauth_client': token.oauth_client.description
|
||||
or token.oauth_client.identifier,
|
||||
}
|
||||
model.update(extra)
|
||||
return model
|
||||
|
||||
def user_model(self, user, include_servers=False, include_state=False):
|
||||
def _filter_model(self, model, access_map, entity, kind, keys=None):
|
||||
"""
|
||||
Filter the model based on the available scopes and the entity requested for.
|
||||
If keys is a dictionary, update it with the allowed keys for the model.
|
||||
"""
|
||||
allowed_keys = set()
|
||||
for scope in access_map:
|
||||
scope_filter = self.get_scope_filter(scope)
|
||||
if scope_filter(entity, kind=kind):
|
||||
allowed_keys |= access_map[scope]
|
||||
model = {key: model[key] for key in allowed_keys if key in model}
|
||||
if isinstance(keys, set):
|
||||
keys.update(allowed_keys)
|
||||
return model
|
||||
|
||||
def user_model(self, user):
|
||||
"""Get the JSON model for a User object"""
|
||||
if isinstance(user, orm.User):
|
||||
user = self.users[user.id]
|
||||
|
||||
model = {
|
||||
'kind': 'user',
|
||||
'name': user.name,
|
||||
'admin': user.admin,
|
||||
'roles': [r.name for r in user.roles],
|
||||
'groups': [g.name for g in user.groups],
|
||||
'server': user.url if user.running else None,
|
||||
'pending': None,
|
||||
'created': isoformat(user.created),
|
||||
'last_activity': isoformat(user.last_activity),
|
||||
'auth_state': None, # placeholder, filled in later
|
||||
}
|
||||
if '' in user.spawners:
|
||||
access_map = {
|
||||
'read:users': {
|
||||
'kind',
|
||||
'name',
|
||||
'admin',
|
||||
'roles',
|
||||
'groups',
|
||||
'server',
|
||||
'pending',
|
||||
'created',
|
||||
'last_activity',
|
||||
},
|
||||
'read:users:name': {'kind', 'name', 'admin'},
|
||||
'read:users:groups': {'kind', 'name', 'groups'},
|
||||
'read:users:activity': {'kind', 'name', 'last_activity'},
|
||||
'read:servers': {'kind', 'name', 'servers'},
|
||||
'read:roles:users': {'kind', 'name', 'roles', 'admin'},
|
||||
'admin:auth_state': {'kind', 'name', 'auth_state'},
|
||||
}
|
||||
self.log.debug(
|
||||
"Asking for user model of %s with scopes [%s]",
|
||||
user.name,
|
||||
", ".join(self.expanded_scopes),
|
||||
)
|
||||
allowed_keys = set()
|
||||
model = self._filter_model(
|
||||
model, access_map, user, kind='user', keys=allowed_keys
|
||||
)
|
||||
if model:
|
||||
if '' in user.spawners and 'pending' in allowed_keys:
|
||||
model['pending'] = user.spawners[''].pending
|
||||
|
||||
if not include_servers:
|
||||
model['servers'] = None
|
||||
return model
|
||||
|
||||
servers = model['servers'] = {}
|
||||
scope_filter = self.get_scope_filter('read:servers')
|
||||
for name, spawner in user.spawners.items():
|
||||
# include 'active' servers, not just ready
|
||||
# (this includes pending events)
|
||||
if spawner.active:
|
||||
servers[name] = self.server_model(spawner, include_state=include_state)
|
||||
if spawner.active and scope_filter(spawner, kind='server'):
|
||||
servers[name] = self.server_model(spawner)
|
||||
if not servers:
|
||||
model.pop('servers')
|
||||
return model
|
||||
|
||||
def group_model(self, group):
|
||||
"""Get the JSON model for a Group object"""
|
||||
return {
|
||||
model = {
|
||||
'kind': 'group',
|
||||
'name': group.name,
|
||||
'roles': [r.name for r in group.roles],
|
||||
'users': [u.name for u in group.users],
|
||||
}
|
||||
access_map = {
|
||||
'read:groups': {'kind', 'name', 'users'},
|
||||
'read:groups:name': {'kind', 'name'},
|
||||
'read:roles:groups': {'kind', 'name', 'roles'},
|
||||
}
|
||||
model = self._filter_model(model, access_map, group, 'group')
|
||||
return model
|
||||
|
||||
def service_model(self, service):
|
||||
"""Get the JSON model for a Service object"""
|
||||
return {'kind': 'service', 'name': service.name, 'admin': service.admin}
|
||||
model = {
|
||||
'kind': 'service',
|
||||
'name': service.name,
|
||||
'roles': [r.name for r in service.roles],
|
||||
'admin': service.admin,
|
||||
'url': getattr(service, 'url', ''),
|
||||
'prefix': service.server.base_url if getattr(service, 'server', '') else '',
|
||||
'command': getattr(service, 'command', ''),
|
||||
'pid': service.proc.pid if getattr(service, 'proc', '') else 0,
|
||||
'info': getattr(service, 'info', ''),
|
||||
'display': getattr(service, 'display', ''),
|
||||
}
|
||||
access_map = {
|
||||
'read:services': {
|
||||
'kind',
|
||||
'name',
|
||||
'admin',
|
||||
'url',
|
||||
'prefix',
|
||||
'command',
|
||||
'pid',
|
||||
'info',
|
||||
'display',
|
||||
},
|
||||
'read:services:name': {'kind', 'name', 'admin'},
|
||||
'read:roles:services': {'kind', 'name', 'roles', 'admin'},
|
||||
}
|
||||
model = self._filter_model(model, access_map, service, 'service')
|
||||
return model
|
||||
|
||||
_user_model_types = {'name': str, 'admin': bool, 'groups': list, 'auth_state': dict}
|
||||
_user_model_types = {
|
||||
'name': str,
|
||||
'admin': bool,
|
||||
'groups': list,
|
||||
'roles': list,
|
||||
'auth_state': dict,
|
||||
}
|
||||
|
||||
_group_model_types = {'name': str, 'users': list}
|
||||
_group_model_types = {'name': str, 'users': list, 'roles': list}
|
||||
|
||||
def _check_model(self, model, model_types, name):
|
||||
"""Check a model provided by a REST API request
|
||||
|
@@ -6,7 +6,7 @@ import json
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only
|
||||
from ..scopes import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
@@ -22,31 +22,31 @@ class _GroupAPIHandler(APIHandler):
|
||||
users.append(user.orm_user)
|
||||
return users
|
||||
|
||||
def find_group(self, name):
|
||||
def find_group(self, group_name):
|
||||
"""Find and return a group by name.
|
||||
|
||||
Raise 404 if not found.
|
||||
"""
|
||||
group = orm.Group.find(self.db, name=name)
|
||||
|
||||
group = orm.Group.find(self.db, name=group_name)
|
||||
if group is None:
|
||||
raise web.HTTPError(404, "No such group: %s", name)
|
||||
raise web.HTTPError(404, "No such group: %s", group_name)
|
||||
return group
|
||||
|
||||
|
||||
class GroupListAPIHandler(_GroupAPIHandler):
|
||||
@admin_only
|
||||
@needs_scope('read:groups', 'read:groups:name', 'read:roles:groups')
|
||||
def get(self):
|
||||
"""List groups"""
|
||||
query = self.db.query(orm.Group)
|
||||
offset, limit = self.get_api_pagination()
|
||||
query = query.offset(offset).limit(limit)
|
||||
data = [self.group_model(group) for group in query]
|
||||
scope_filter = self.get_scope_filter('read:groups')
|
||||
data = [self.group_model(g) for g in query if scope_filter(g, kind='group')]
|
||||
self.write(json.dumps(data))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:groups')
|
||||
async def post(self):
|
||||
"""POST creates Multiple groups """
|
||||
"""POST creates Multiple groups"""
|
||||
model = self.get_json_body()
|
||||
if not model or not isinstance(model, dict) or not model.get('groups'):
|
||||
raise web.HTTPError(400, "Must specify at least one group to create")
|
||||
@@ -77,14 +77,13 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
||||
class GroupAPIHandler(_GroupAPIHandler):
|
||||
"""View and modify groups by name"""
|
||||
|
||||
@admin_only
|
||||
def get(self, name):
|
||||
group = self.find_group(name)
|
||||
group_model = self.group_model(group)
|
||||
self.write(json.dumps(group_model))
|
||||
@needs_scope('read:groups', 'read:groups:name', 'read:roles:groups')
|
||||
def get(self, group_name):
|
||||
group = self.find_group(group_name)
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
@admin_only
|
||||
async def post(self, name):
|
||||
@needs_scope('admin:groups')
|
||||
async def post(self, group_name):
|
||||
"""POST creates a group by name"""
|
||||
model = self.get_json_body()
|
||||
if model is None:
|
||||
@@ -92,28 +91,28 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
else:
|
||||
self._check_group_model(model)
|
||||
|
||||
existing = orm.Group.find(self.db, name=name)
|
||||
existing = orm.Group.find(self.db, name=group_name)
|
||||
if existing is not None:
|
||||
raise web.HTTPError(409, "Group %s already exists" % name)
|
||||
raise web.HTTPError(409, "Group %s already exists" % group_name)
|
||||
|
||||
usernames = model.get('users', [])
|
||||
# check that users exist
|
||||
users = self._usernames_to_users(usernames)
|
||||
|
||||
# create the group
|
||||
self.log.info("Creating new group %s with %i users", name, len(users))
|
||||
self.log.info("Creating new group %s with %i users", group_name, len(users))
|
||||
self.log.debug("Users: %s", usernames)
|
||||
group = orm.Group(name=name, users=users)
|
||||
group = orm.Group(name=group_name, users=users)
|
||||
self.db.add(group)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
self.set_status(201)
|
||||
|
||||
@admin_only
|
||||
def delete(self, name):
|
||||
@needs_scope('admin:groups')
|
||||
def delete(self, group_name):
|
||||
"""Delete a group by name"""
|
||||
group = self.find_group(name)
|
||||
self.log.info("Deleting group %s", name)
|
||||
group = self.find_group(group_name)
|
||||
self.log.info("Deleting group %s", group_name)
|
||||
self.db.delete(group)
|
||||
self.db.commit()
|
||||
self.set_status(204)
|
||||
@@ -122,39 +121,41 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
"""Modify a group's user list"""
|
||||
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
@needs_scope('groups')
|
||||
def post(self, group_name):
|
||||
"""POST adds users to a group"""
|
||||
group = self.find_group(name)
|
||||
group = self.find_group(group_name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
if 'users' not in data:
|
||||
raise web.HTTPError(400, "Must specify users to add")
|
||||
self.log.info("Adding %i users to group %s", len(data['users']), name)
|
||||
self.log.info("Adding %i users to group %s", len(data['users']), group_name)
|
||||
self.log.debug("Adding: %s", data['users'])
|
||||
for user in self._usernames_to_users(data['users']):
|
||||
if user not in group.users:
|
||||
group.users.append(user)
|
||||
else:
|
||||
self.log.warning("User %s already in group %s", user.name, name)
|
||||
self.log.warning("User %s already in group %s", user.name, group_name)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
@admin_only
|
||||
async def delete(self, name):
|
||||
@needs_scope('groups')
|
||||
async def delete(self, group_name):
|
||||
"""DELETE removes users from a group"""
|
||||
group = self.find_group(name)
|
||||
group = self.find_group(group_name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
if 'users' not in data:
|
||||
raise web.HTTPError(400, "Must specify users to delete")
|
||||
self.log.info("Removing %i users from group %s", len(data['users']), name)
|
||||
self.log.info("Removing %i users from group %s", len(data['users']), group_name)
|
||||
self.log.debug("Removing: %s", data['users'])
|
||||
for user in self._usernames_to_users(data['users']):
|
||||
if user in group.users:
|
||||
group.users.remove(user)
|
||||
else:
|
||||
self.log.warning("User %s already not in group %s", user.name, name)
|
||||
self.log.warning(
|
||||
"User %s already not in group %s", user.name, group_name
|
||||
)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
|
@@ -8,12 +8,12 @@ from tornado import web
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from .._version import __version__
|
||||
from ..utils import admin_only
|
||||
from ..scopes import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class ShutdownAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('shutdown')
|
||||
def post(self):
|
||||
"""POST /api/shutdown triggers a clean shutdown
|
||||
|
||||
@@ -56,8 +56,7 @@ class RootAPIHandler(APIHandler):
|
||||
def get(self):
|
||||
"""GET /api/ returns info about the Hub and its API.
|
||||
|
||||
It is not an authenticated endpoint.
|
||||
|
||||
It is not an authenticated endpoint
|
||||
For now, it just returns the version of JupyterHub itself.
|
||||
"""
|
||||
data = {'version': __version__}
|
||||
@@ -65,13 +64,12 @@ class RootAPIHandler(APIHandler):
|
||||
|
||||
|
||||
class InfoAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('read:hub')
|
||||
def get(self):
|
||||
"""GET /api/info returns detailed info about the Hub and its API.
|
||||
|
||||
It is not an authenticated endpoint.
|
||||
|
||||
For now, it just returns the version of JupyterHub itself.
|
||||
Currently, it returns information on the python version, spawner and authenticator.
|
||||
Since this information might be sensitive, it is an authenticated endpoint
|
||||
"""
|
||||
|
||||
def _class_info(typ):
|
||||
|
@@ -5,12 +5,12 @@ import json
|
||||
|
||||
from tornado import web
|
||||
|
||||
from ..utils import admin_only
|
||||
from ..scopes import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class ProxyAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('proxy')
|
||||
async def get(self):
|
||||
"""GET /api/proxy fetches the routing table
|
||||
|
||||
@@ -29,7 +29,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
|
||||
self.write(json.dumps(routes))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('proxy')
|
||||
async def post(self):
|
||||
"""POST checks the proxy to ensure that it's up to date.
|
||||
|
||||
@@ -38,7 +38,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
"""
|
||||
await self.proxy.check_routes(self.users, self.services)
|
||||
|
||||
@admin_only
|
||||
@needs_scope('proxy')
|
||||
async def patch(self):
|
||||
"""PATCH updates the location of the proxy
|
||||
|
||||
|
@@ -6,60 +6,26 @@ Currently GET-only, no actions can be taken to modify services.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only
|
||||
from ..scopes import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
def service_model(service):
|
||||
"""Produce the model for a service"""
|
||||
return {
|
||||
'name': service.name,
|
||||
'admin': service.admin,
|
||||
'url': service.url,
|
||||
'prefix': service.server.base_url if service.server else '',
|
||||
'command': service.command,
|
||||
'pid': service.proc.pid if service.proc else 0,
|
||||
'info': service.info,
|
||||
'display': service.display,
|
||||
}
|
||||
|
||||
|
||||
class ServiceListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('read:services', 'read:services:name', 'read:roles:services')
|
||||
def get(self):
|
||||
data = {name: service_model(service) for name, service in self.services.items()}
|
||||
data = {}
|
||||
for name, service in self.services.items():
|
||||
model = self.service_model(service)
|
||||
if model:
|
||||
data[name] = model
|
||||
self.write(json.dumps(data))
|
||||
|
||||
|
||||
def admin_or_self(method):
|
||||
"""Decorator for restricting access to either the target service or admin"""
|
||||
|
||||
def decorated_method(self, name):
|
||||
current = self.current_user
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
if not current.admin:
|
||||
# not admin, maybe self
|
||||
if not isinstance(current, orm.Service):
|
||||
raise web.HTTPError(403)
|
||||
if current.name != name:
|
||||
raise web.HTTPError(403)
|
||||
# raise 404 if not found
|
||||
if name not in self.services:
|
||||
raise web.HTTPError(404)
|
||||
return method(self, name)
|
||||
|
||||
return decorated_method
|
||||
|
||||
|
||||
class ServiceAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
def get(self, name):
|
||||
service = self.services[name]
|
||||
self.write(json.dumps(service_model(service)))
|
||||
@needs_scope('read:services', 'read:services:name', 'read:roles:services')
|
||||
def get(self, service_name):
|
||||
service = self.services[service_name]
|
||||
self.write(json.dumps(self.service_model(service)))
|
||||
|
||||
|
||||
default_handlers = [
|
||||
|
@@ -14,8 +14,10 @@ from tornado import web
|
||||
from tornado.iostream import StreamClosedError
|
||||
|
||||
from .. import orm
|
||||
from .. import scopes
|
||||
from ..roles import assign_default_roles
|
||||
from ..scopes import needs_scope
|
||||
from ..user import User
|
||||
from ..utils import admin_only
|
||||
from ..utils import isoformat
|
||||
from ..utils import iterate_until
|
||||
from ..utils import maybe_future
|
||||
@@ -31,15 +33,33 @@ class SelfAPIHandler(APIHandler):
|
||||
|
||||
async def get(self):
|
||||
user = self.current_user
|
||||
if user is None:
|
||||
# whoami can be accessed via oauth token
|
||||
user = self.get_current_user_oauth_token()
|
||||
if user is None:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
_added_scopes = set()
|
||||
if isinstance(user, orm.Service):
|
||||
model = self.service_model(user)
|
||||
# ensure we have the minimal 'identify' scopes for the token owner
|
||||
identify_scopes = scopes.identify_scopes(user)
|
||||
get_model = self.service_model
|
||||
else:
|
||||
model = self.user_model(user)
|
||||
identify_scopes = scopes.identify_scopes(user.orm_user)
|
||||
get_model = self.user_model
|
||||
|
||||
# ensure we have permission to identify ourselves
|
||||
# all tokens can do this on this endpoint
|
||||
for scope in identify_scopes:
|
||||
if scope not in self.expanded_scopes:
|
||||
_added_scopes.add(scope)
|
||||
self.expanded_scopes.add(scope)
|
||||
if _added_scopes:
|
||||
# re-parse with new scopes
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
|
||||
model = get_model(user)
|
||||
|
||||
# add scopes to identify model,
|
||||
# but not the scopes we added to ensure we could read our own model
|
||||
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
|
||||
self.write(json.dumps(model))
|
||||
|
||||
|
||||
@@ -52,7 +72,14 @@ class UserListAPIHandler(APIHandler):
|
||||
user = self.users[orm_user]
|
||||
return any(spawner.ready for spawner in user.spawners.values())
|
||||
|
||||
@admin_only
|
||||
@needs_scope(
|
||||
'read:users',
|
||||
'read:users:name',
|
||||
'read:servers',
|
||||
'read:users:groups',
|
||||
'read:users:activity',
|
||||
'read:roles:users',
|
||||
)
|
||||
def get(self):
|
||||
state_filter = self.get_argument("state", None)
|
||||
offset, limit = self.get_api_pagination()
|
||||
@@ -98,15 +125,16 @@ class UserListAPIHandler(APIHandler):
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
data = [
|
||||
self.user_model(u, include_servers=True, include_state=True)
|
||||
for u in query
|
||||
if (post_filter is None or post_filter(u))
|
||||
]
|
||||
data = []
|
||||
for u in query:
|
||||
if post_filter is None or post_filter(u):
|
||||
user_model = self.user_model(u)
|
||||
if user_model:
|
||||
data.append(user_model)
|
||||
|
||||
self.write(json.dumps(data))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:users')
|
||||
async def post(self):
|
||||
data = self.get_json_body()
|
||||
if not data or not isinstance(data, dict) or not data.get('usernames'):
|
||||
@@ -146,6 +174,7 @@ class UserListAPIHandler(APIHandler):
|
||||
user = self.user_from_username(name)
|
||||
if admin:
|
||||
user.admin = True
|
||||
assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
try:
|
||||
await maybe_future(self.authenticator.add_user(user))
|
||||
@@ -162,82 +191,71 @@ class UserListAPIHandler(APIHandler):
|
||||
self.set_status(201)
|
||||
|
||||
|
||||
def admin_or_self(method):
|
||||
"""Decorator for restricting access to either the target user or admin"""
|
||||
|
||||
def m(self, name, *args, **kwargs):
|
||||
current = self.current_user
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
if not (current.name == name or current.admin):
|
||||
raise web.HTTPError(403)
|
||||
|
||||
# raise 404 if not found
|
||||
if not self.find_user(name):
|
||||
raise web.HTTPError(404)
|
||||
return method(self, name, *args, **kwargs)
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class UserAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
async def get(self, name):
|
||||
user = self.find_user(name)
|
||||
model = self.user_model(
|
||||
user, include_servers=True, include_state=self.current_user.admin
|
||||
@needs_scope(
|
||||
'read:users',
|
||||
'read:users:name',
|
||||
'read:servers',
|
||||
'read:users:groups',
|
||||
'read:users:activity',
|
||||
'read:roles:users',
|
||||
)
|
||||
async def get(self, user_name):
|
||||
user = self.find_user(user_name)
|
||||
model = self.user_model(user)
|
||||
# auth state will only be shown if the requester is an admin
|
||||
# this means users can't see their own auth state unless they
|
||||
# are admins, Hub admins often are also marked as admins so they
|
||||
# will see their auth state but normal users won't
|
||||
requester = self.current_user
|
||||
if requester.admin:
|
||||
if 'auth_state' in model:
|
||||
model['auth_state'] = await user.get_auth_state()
|
||||
self.write(json.dumps(model))
|
||||
|
||||
@admin_only
|
||||
async def post(self, name):
|
||||
@needs_scope('admin:users')
|
||||
async def post(self, user_name):
|
||||
data = self.get_json_body()
|
||||
user = self.find_user(name)
|
||||
user = self.find_user(user_name)
|
||||
if user is not None:
|
||||
raise web.HTTPError(409, "User %s already exists" % name)
|
||||
raise web.HTTPError(409, "User %s already exists" % user_name)
|
||||
|
||||
user = self.user_from_username(name)
|
||||
user = self.user_from_username(user_name)
|
||||
if data:
|
||||
self._check_user_model(data)
|
||||
if 'admin' in data:
|
||||
user.admin = data['admin']
|
||||
assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
|
||||
try:
|
||||
await maybe_future(self.authenticator.add_user(user))
|
||||
except Exception:
|
||||
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
||||
self.log.error("Failed to create user: %s" % user_name, exc_info=True)
|
||||
# remove from registry
|
||||
self.users.delete(user)
|
||||
raise web.HTTPError(400, "Failed to create user: %s" % name)
|
||||
raise web.HTTPError(400, "Failed to create user: %s" % user_name)
|
||||
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
self.set_status(201)
|
||||
|
||||
@admin_only
|
||||
async def delete(self, name):
|
||||
user = self.find_user(name)
|
||||
@needs_scope('admin:users')
|
||||
async def delete(self, user_name):
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
if user.name == self.current_user.name:
|
||||
raise web.HTTPError(400, "Cannot delete yourself!")
|
||||
if user.spawner._stop_pending:
|
||||
raise web.HTTPError(
|
||||
400, "%s's server is in the process of stopping, please wait." % name
|
||||
400,
|
||||
"%s's server is in the process of stopping, please wait." % user_name,
|
||||
)
|
||||
if user.running:
|
||||
await self.stop_single_user(user)
|
||||
if user.spawner._stop_pending:
|
||||
raise web.HTTPError(
|
||||
400,
|
||||
"%s's server is in the process of stopping, please wait." % name,
|
||||
"%s's server is in the process of stopping, please wait."
|
||||
% user_name,
|
||||
)
|
||||
|
||||
await maybe_future(self.authenticator.delete_user(user))
|
||||
@@ -249,14 +267,14 @@ class UserAPIHandler(APIHandler):
|
||||
|
||||
self.set_status(204)
|
||||
|
||||
@admin_only
|
||||
async def patch(self, name):
|
||||
user = self.find_user(name)
|
||||
@needs_scope('admin:users')
|
||||
async def patch(self, user_name):
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
data = self.get_json_body()
|
||||
self._check_user_model(data)
|
||||
if 'name' in data and data['name'] != name:
|
||||
if 'name' in data and data['name'] != user_name:
|
||||
# check if the new name is already taken inside db
|
||||
if self.find_user(data['name']):
|
||||
raise web.HTTPError(
|
||||
@@ -268,6 +286,8 @@ class UserAPIHandler(APIHandler):
|
||||
await user.save_auth_state(value)
|
||||
else:
|
||||
setattr(user, key, value)
|
||||
if key == 'admin':
|
||||
assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
user_ = self.user_model(user)
|
||||
user_['auth_state'] = await user.get_auth_state()
|
||||
@@ -277,15 +297,14 @@ class UserAPIHandler(APIHandler):
|
||||
class UserTokenListAPIHandler(APIHandler):
|
||||
"""API endpoint for listing/creating tokens"""
|
||||
|
||||
@admin_or_self
|
||||
def get(self, name):
|
||||
@needs_scope('read:tokens')
|
||||
def get(self, user_name):
|
||||
"""Get tokens for a given user"""
|
||||
user = self.find_user(name)
|
||||
user = self.find_user(user_name)
|
||||
if not user:
|
||||
raise web.HTTPError(404, "No such user: %s" % name)
|
||||
raise web.HTTPError(404, "No such user: %s" % user_name)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
api_tokens = []
|
||||
|
||||
def sort_key(token):
|
||||
@@ -299,19 +318,9 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
continue
|
||||
api_tokens.append(self.token_model(token))
|
||||
|
||||
oauth_tokens = []
|
||||
# OAuth tokens use integer timestamps
|
||||
now_timestamp = now.timestamp()
|
||||
for token in sorted(user.oauth_tokens, key=sort_key):
|
||||
if token.expires_at and token.expires_at < now_timestamp:
|
||||
# exclude expired tokens
|
||||
self.db.delete(token)
|
||||
self.db.commit()
|
||||
continue
|
||||
oauth_tokens.append(self.token_model(token))
|
||||
self.write(json.dumps({'api_tokens': api_tokens, 'oauth_tokens': oauth_tokens}))
|
||||
self.write(json.dumps({'api_tokens': api_tokens}))
|
||||
|
||||
async def post(self, name):
|
||||
async def post(self, user_name):
|
||||
body = self.get_json_body() or {}
|
||||
if not isinstance(body, dict):
|
||||
raise web.HTTPError(400, "Body must be a JSON dict or empty")
|
||||
@@ -339,13 +348,16 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
if requester is None:
|
||||
# couldn't identify requester
|
||||
raise web.HTTPError(403)
|
||||
user = self.find_user(name)
|
||||
if requester is not user and not requester.admin:
|
||||
raise web.HTTPError(403, "Only admins can request tokens for other users")
|
||||
if not user:
|
||||
raise web.HTTPError(404, "No such user: %s" % name)
|
||||
if requester is not user:
|
||||
self._jupyterhub_user = requester
|
||||
self._resolve_roles_and_scopes()
|
||||
user = self.find_user(user_name)
|
||||
kind = 'user' if isinstance(requester, User) else 'service'
|
||||
scope_filter = self.get_scope_filter('tokens')
|
||||
if user is None or not scope_filter(user, kind):
|
||||
raise web.HTTPError(
|
||||
403,
|
||||
f"{kind.title()} {user_name} not found or no permissions to generate tokens",
|
||||
)
|
||||
|
||||
note = body.get('note')
|
||||
if not note:
|
||||
@@ -353,8 +365,18 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
if requester is not user:
|
||||
note += " by %s %s" % (kind, requester.name)
|
||||
|
||||
token_roles = body.get('roles')
|
||||
try:
|
||||
api_token = user.new_api_token(
|
||||
note=note, expires_in=body.get('expires_in', None)
|
||||
note=note, expires_in=body.get('expires_in', None), roles=token_roles
|
||||
)
|
||||
except NameError:
|
||||
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
|
||||
except ValueError:
|
||||
raise web.HTTPError(
|
||||
403,
|
||||
"Requested roles %r cannot have higher permissions than the token owner"
|
||||
% token_roles,
|
||||
)
|
||||
if requester is not user:
|
||||
self.log.info(
|
||||
@@ -382,44 +404,40 @@ class UserTokenAPIHandler(APIHandler):
|
||||
(e.g. wrong owner, invalid key format, etc.)
|
||||
"""
|
||||
not_found = "No such token %s for user %s" % (token_id, user.name)
|
||||
prefix, id = token_id[0], token_id[1:]
|
||||
if prefix == 'a':
|
||||
Token = orm.APIToken
|
||||
elif prefix == 'o':
|
||||
Token = orm.OAuthAccessToken
|
||||
else:
|
||||
prefix, id_ = token_id[:1], token_id[1:]
|
||||
if prefix != 'a':
|
||||
raise web.HTTPError(404, not_found)
|
||||
try:
|
||||
id = int(id)
|
||||
id_ = int(id_)
|
||||
except ValueError:
|
||||
raise web.HTTPError(404, not_found)
|
||||
|
||||
orm_token = self.db.query(Token).filter(Token.id == id).first()
|
||||
orm_token = self.db.query(orm.APIToken).filter_by(id=id_).first()
|
||||
if orm_token is None or orm_token.user is not user.orm_user:
|
||||
raise web.HTTPError(404, "Token not found %s", orm_token)
|
||||
return orm_token
|
||||
|
||||
@admin_or_self
|
||||
def get(self, name, token_id):
|
||||
@needs_scope('read:tokens')
|
||||
def get(self, user_name, token_id):
|
||||
""""""
|
||||
user = self.find_user(name)
|
||||
user = self.find_user(user_name)
|
||||
if not user:
|
||||
raise web.HTTPError(404, "No such user: %s" % name)
|
||||
raise web.HTTPError(404, "No such user: %s" % user_name)
|
||||
token = self.find_token_by_id(user, token_id)
|
||||
self.write(json.dumps(self.token_model(token)))
|
||||
|
||||
@admin_or_self
|
||||
def delete(self, name, token_id):
|
||||
@needs_scope('tokens')
|
||||
def delete(self, user_name, token_id):
|
||||
"""Delete a token"""
|
||||
user = self.find_user(name)
|
||||
user = self.find_user(user_name)
|
||||
if not user:
|
||||
raise web.HTTPError(404, "No such user: %s" % name)
|
||||
raise web.HTTPError(404, "No such user: %s" % user_name)
|
||||
token = self.find_token_by_id(user, token_id)
|
||||
# deleting an oauth token deletes *all* oauth tokens for that client
|
||||
if isinstance(token, orm.OAuthAccessToken):
|
||||
client_id = token.client_id
|
||||
if token.client_id != "jupyterhub":
|
||||
tokens = [
|
||||
token for token in user.oauth_tokens if token.client_id == client_id
|
||||
token for token in user.api_tokens if token.client_id == client_id
|
||||
]
|
||||
else:
|
||||
tokens = [token]
|
||||
@@ -433,9 +451,9 @@ class UserTokenAPIHandler(APIHandler):
|
||||
class UserServerAPIHandler(APIHandler):
|
||||
"""Start and stop single-user servers"""
|
||||
|
||||
@admin_or_self
|
||||
async def post(self, name, server_name=''):
|
||||
user = self.find_user(name)
|
||||
@needs_scope('servers')
|
||||
async def post(self, user_name, server_name=''):
|
||||
user = self.find_user(user_name)
|
||||
if server_name:
|
||||
if not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
@@ -449,7 +467,7 @@ class UserServerAPIHandler(APIHandler):
|
||||
400,
|
||||
"User {} already has the maximum of {} named servers."
|
||||
" One must be deleted before a new server can be created".format(
|
||||
name, self.named_server_limit_per_user
|
||||
user_name, self.named_server_limit_per_user
|
||||
),
|
||||
)
|
||||
spawner = user.spawners[server_name]
|
||||
@@ -478,9 +496,9 @@ class UserServerAPIHandler(APIHandler):
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
|
||||
@admin_or_self
|
||||
async def delete(self, name, server_name=''):
|
||||
user = self.find_user(name)
|
||||
@needs_scope('servers')
|
||||
async def delete(self, user_name, server_name=''):
|
||||
user = self.find_user(user_name)
|
||||
options = self.get_json_body()
|
||||
remove = (options or {}).get('remove', False)
|
||||
|
||||
@@ -505,7 +523,7 @@ class UserServerAPIHandler(APIHandler):
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
if server_name not in user.orm_spawners:
|
||||
raise web.HTTPError(
|
||||
404, "%s has no server named '%s'" % (name, server_name)
|
||||
404, "%s has no server named '%s'" % (user_name, server_name)
|
||||
)
|
||||
elif remove:
|
||||
raise web.HTTPError(400, "Cannot delete the default server")
|
||||
@@ -551,19 +569,19 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
This handler sets the necessary cookie for an admin to login to a single-user server.
|
||||
"""
|
||||
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
@needs_scope('servers')
|
||||
def post(self, user_name):
|
||||
self.log.warning(
|
||||
"Deprecated in JupyterHub 0.8."
|
||||
" Admin access API is not needed now that we use OAuth."
|
||||
)
|
||||
current = self.current_user
|
||||
self.log.warning(
|
||||
"Admin user %s has requested access to %s's server", current.name, name
|
||||
"Admin user %s has requested access to %s's server", current.name, user_name
|
||||
)
|
||||
if not self.settings.get('admin_access', False):
|
||||
raise web.HTTPError(403, "admin access to user servers disabled")
|
||||
user = self.find_user(name)
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
|
||||
@@ -607,12 +625,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
|
||||
await asyncio.wait([self._finish_future], timeout=self.keepalive_interval)
|
||||
|
||||
@admin_or_self
|
||||
async def get(self, username, server_name=''):
|
||||
@needs_scope('read:servers')
|
||||
async def get(self, user_name, server_name=''):
|
||||
self.set_header('Cache-Control', 'no-cache')
|
||||
if server_name is None:
|
||||
server_name = ''
|
||||
user = self.find_user(username)
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
# no such user
|
||||
raise web.HTTPError(404)
|
||||
@@ -750,12 +768,12 @@ class ActivityAPIHandler(APIHandler):
|
||||
)
|
||||
return servers
|
||||
|
||||
@admin_or_self
|
||||
def post(self, username):
|
||||
user = self.find_user(username)
|
||||
@needs_scope('users:activity')
|
||||
def post(self, user_name):
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
# no such user
|
||||
raise web.HTTPError(404, "No such user: %r", username)
|
||||
raise web.HTTPError(404, "No such user: %r", user_name)
|
||||
|
||||
body = self.get_json_body()
|
||||
if not isinstance(body, dict):
|
||||
|
@@ -21,6 +21,7 @@ from datetime import timezone
|
||||
from functools import partial
|
||||
from getpass import getuser
|
||||
from glob import glob
|
||||
from itertools import chain
|
||||
from operator import itemgetter
|
||||
from textwrap import dedent
|
||||
from urllib.parse import unquote
|
||||
@@ -82,6 +83,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
|
||||
@@ -110,7 +112,6 @@ from .objects import Hub, Server
|
||||
# For faking stats
|
||||
from .emptyclass import EmptyClass
|
||||
|
||||
|
||||
common_aliases = {
|
||||
'log-level': 'Application.log_level',
|
||||
'f': 'JupyterHub.config_file',
|
||||
@@ -118,7 +119,6 @@ common_aliases = {
|
||||
'db': 'JupyterHub.db_url',
|
||||
}
|
||||
|
||||
|
||||
aliases = {
|
||||
'base-url': 'JupyterHub.base_url',
|
||||
'y': 'JupyterHub.answer_yes',
|
||||
@@ -214,11 +214,12 @@ class NewToken(Application):
|
||||
hub.load_config_file(hub.config_file)
|
||||
hub.init_db()
|
||||
|
||||
def init_users():
|
||||
def init_roles_and_users():
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(hub.init_role_creation())
|
||||
loop.run_until_complete(hub.init_users())
|
||||
|
||||
ThreadPoolExecutor(1).submit(init_users).result()
|
||||
ThreadPoolExecutor(1).submit(init_roles_and_users).result()
|
||||
user = orm.User.find(hub.db, self.name)
|
||||
if user is None:
|
||||
print("No such user: %s" % self.name, file=sys.stderr)
|
||||
@@ -321,6 +322,31 @@ class JupyterHub(Application):
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
load_roles = List(
|
||||
Dict(),
|
||||
help="""List of predefined role dictionaries to load at startup.
|
||||
|
||||
For instance::
|
||||
|
||||
load_roles = [
|
||||
{
|
||||
'name': 'teacher',
|
||||
'description': 'Access to users' information and group membership',
|
||||
'scopes': ['users', 'groups'],
|
||||
'users': ['cyclops', 'gandalf'],
|
||||
'services': [],
|
||||
'groups': []
|
||||
}
|
||||
]
|
||||
|
||||
All keys apart from 'name' are optional.
|
||||
See all the available scopes in the JupyterHub REST API documentation.
|
||||
|
||||
Default roles are defined in roles.py.
|
||||
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
config_file = Unicode('jupyterhub_config.py', help="The config file to load").tag(
|
||||
config=True
|
||||
)
|
||||
@@ -1419,11 +1445,13 @@ class JupyterHub(Application):
|
||||
max(self.log_level, logging.INFO)
|
||||
)
|
||||
|
||||
# hook up tornado 3's loggers to our app handlers
|
||||
for log in (app_log, access_log, gen_log):
|
||||
# ensure all log statements identify the application they come from
|
||||
log.name = self.log.name
|
||||
logger = logging.getLogger('tornado')
|
||||
|
||||
# hook up tornado's and oauthlib's loggers to our own
|
||||
for name in ("tornado", "oauthlib"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.propagate = True
|
||||
logger.parent = self.log
|
||||
logger.setLevel(self.log.level)
|
||||
@@ -1734,6 +1762,26 @@ class JupyterHub(Application):
|
||||
except orm.DatabaseSchemaMismatch as e:
|
||||
self.exit(e)
|
||||
|
||||
# ensure the default oauth client exists
|
||||
if (
|
||||
not self.db.query(orm.OAuthClient)
|
||||
.filter_by(identifier="jupyterhub")
|
||||
.one_or_none()
|
||||
):
|
||||
# create the oauth client for jupyterhub itself
|
||||
# this allows us to distinguish between orphaned tokens
|
||||
# (failed cascade deletion) and tokens issued by the hub
|
||||
# it has no client_secret, which means it cannot be used
|
||||
# to make requests
|
||||
client = orm.OAuthClient(
|
||||
identifier="jupyterhub",
|
||||
secret="",
|
||||
redirect_uri="",
|
||||
description="JupyterHub",
|
||||
)
|
||||
self.db.add(client)
|
||||
self.db.commit()
|
||||
|
||||
def init_hub(self):
|
||||
"""Load the Hub URL config"""
|
||||
hub_args = dict(
|
||||
@@ -1830,7 +1878,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.
|
||||
|
||||
@@ -1904,31 +1951,189 @@ class JupyterHub(Application):
|
||||
|
||||
TOTAL_USERS.set(total_users)
|
||||
|
||||
async def _get_or_create_user(self, username):
|
||||
"""Create user if username is found in config but user does not exist"""
|
||||
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(self.db, name=username)
|
||||
if user is None:
|
||||
if not self.authenticator.validate_username(username):
|
||||
raise ValueError("Username %r is not valid" % username)
|
||||
self.log.info(f"Creating user {username}")
|
||||
user = orm.User(name=username)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
return user
|
||||
|
||||
async def init_groups(self):
|
||||
"""Load predefined groups into the database"""
|
||||
db = self.db
|
||||
for name, usernames in self.load_groups.items():
|
||||
group = orm.Group.find(db, name)
|
||||
if group is None:
|
||||
self.log.info(f"Creating group {name}")
|
||||
group = orm.Group(name=name)
|
||||
db.add(group)
|
||||
for username in usernames:
|
||||
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("Group username %r is not valid" % username)
|
||||
user = orm.User(name=username)
|
||||
db.add(user)
|
||||
user = await self._get_or_create_user(username)
|
||||
self.log.debug(f"Adding user {username} to group {name}")
|
||||
group.users.append(user)
|
||||
db.commit()
|
||||
|
||||
async def init_role_creation(self):
|
||||
"""Load default and predefined roles into the database"""
|
||||
self.log.debug('Loading default roles to database')
|
||||
default_roles = roles.get_default_roles()
|
||||
config_role_names = [r['name'] for r in self.load_roles]
|
||||
|
||||
init_roles = default_roles
|
||||
roles_with_new_permissions = []
|
||||
for role_spec in self.load_roles:
|
||||
role_name = role_spec['name']
|
||||
# Check for duplicates
|
||||
if config_role_names.count(role_name) > 1:
|
||||
raise ValueError(
|
||||
f"Role {role_name} multiply defined. Please check the `load_roles` configuration"
|
||||
)
|
||||
init_roles.append(role_spec)
|
||||
# Check if some roles have obtained new permissions (to avoid 'scope creep')
|
||||
old_role = orm.Role.find(self.db, name=role_name)
|
||||
if old_role:
|
||||
if not set(role_spec['scopes']).issubset(old_role.scopes):
|
||||
app_log.warning(
|
||||
"Role %s has obtained extra permissions" % role_name
|
||||
)
|
||||
roles_with_new_permissions.append(role_name)
|
||||
if roles_with_new_permissions:
|
||||
unauthorized_oauth_tokens = (
|
||||
self.db.query(orm.APIToken)
|
||||
.filter(
|
||||
orm.APIToken.roles.any(
|
||||
orm.Role.name.in_(roles_with_new_permissions)
|
||||
)
|
||||
)
|
||||
.filter(orm.APIToken.client_id != 'jupyterhub')
|
||||
)
|
||||
for token in unauthorized_oauth_tokens:
|
||||
app_log.warning(
|
||||
"Deleting OAuth token %s; one of its roles obtained new permissions that were not authorized by user"
|
||||
% token
|
||||
)
|
||||
self.db.delete(token)
|
||||
self.db.commit()
|
||||
|
||||
init_role_names = [r['name'] for r in init_roles]
|
||||
if not orm.Role.find(self.db, name='admin'):
|
||||
self._rbac_upgrade = True
|
||||
else:
|
||||
self._rbac_upgrade = False
|
||||
for role in self.db.query(orm.Role).filter(
|
||||
orm.Role.name.notin_(init_role_names)
|
||||
):
|
||||
app_log.info(f"Deleting role {role.name}")
|
||||
self.db.delete(role)
|
||||
self.db.commit()
|
||||
for role in init_roles:
|
||||
roles.create_role(self.db, role)
|
||||
|
||||
async def init_role_assignment(self):
|
||||
# tokens are added separately
|
||||
kinds = ['users', 'services', 'groups']
|
||||
admin_role_objects = ['users', 'services']
|
||||
config_admin_users = set(self.authenticator.admin_users)
|
||||
db = self.db
|
||||
# load predefined roles from config file
|
||||
if config_admin_users:
|
||||
for role_spec in self.load_roles:
|
||||
if role_spec['name'] == 'admin':
|
||||
app_log.warning(
|
||||
"Configuration specifies both admin_users and users in the admin role specification. "
|
||||
"If admin role is present in config, c.authenticator.admin_users should not be used."
|
||||
)
|
||||
app_log.info(
|
||||
"Merging admin_users set with users list in admin role"
|
||||
)
|
||||
role_spec['users'] = set(role_spec.get('users', []))
|
||||
role_spec['users'] |= config_admin_users
|
||||
self.log.debug('Loading predefined roles from config file to database')
|
||||
has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
|
||||
for predef_role in self.load_roles:
|
||||
predef_role_obj = orm.Role.find(db, name=predef_role['name'])
|
||||
if predef_role['name'] == 'admin':
|
||||
for kind in admin_role_objects:
|
||||
has_admin_role_spec[kind] = kind in predef_role
|
||||
if has_admin_role_spec[kind]:
|
||||
app_log.info(f"Admin role specifies static {kind} list")
|
||||
else:
|
||||
app_log.info(
|
||||
f"Admin role does not specify {kind}, preserving admin membership in database"
|
||||
)
|
||||
# add users, services, and/or groups,
|
||||
# tokens need to be checked for permissions
|
||||
for kind in kinds:
|
||||
orm_role_bearers = []
|
||||
if kind in predef_role.keys():
|
||||
for bname in predef_role[kind]:
|
||||
if kind == '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
|
||||
)
|
||||
Class = orm.get_class(kind)
|
||||
orm_obj = Class.find(db, bname)
|
||||
if orm_obj:
|
||||
orm_role_bearers.append(orm_obj)
|
||||
else:
|
||||
app_log.info(
|
||||
f"Found unexisting {kind} {bname} in role definition {predef_role['name']}"
|
||||
)
|
||||
if kind == 'users':
|
||||
orm_obj = await self._get_or_create_user(bname)
|
||||
orm_role_bearers.append(orm_obj)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"
|
||||
)
|
||||
# Ensure all with admin role have admin flag
|
||||
if predef_role['name'] == 'admin':
|
||||
orm_obj.admin = True
|
||||
setattr(predef_role_obj, kind, orm_role_bearers)
|
||||
db.commit()
|
||||
if self.authenticator.allowed_users:
|
||||
allowed_users = db.query(orm.User).filter(
|
||||
orm.User.name.in_(self.authenticator.allowed_users)
|
||||
)
|
||||
for user in allowed_users:
|
||||
roles.grant_role(db, user, 'user')
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
for kind in admin_role_objects:
|
||||
Class = orm.get_class(kind)
|
||||
for admin_obj in db.query(Class).filter_by(admin=True):
|
||||
if has_admin_role_spec[kind]:
|
||||
admin_obj.admin = admin_role in admin_obj.roles
|
||||
else:
|
||||
roles.grant_role(db, admin_obj, 'admin')
|
||||
db.commit()
|
||||
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
|
||||
if getattr(self, '_rbac_upgrade', False):
|
||||
app_log.warning(
|
||||
"No admin role found; assuming hub upgrade. Initializing default roles for all entities"
|
||||
)
|
||||
for kind in kinds:
|
||||
roles.check_for_default_roles(db, kind)
|
||||
|
||||
# check tokens for default roles
|
||||
roles.check_for_default_roles(db, bearer='tokens')
|
||||
|
||||
async def _add_tokens(self, token_dict, kind):
|
||||
"""Add tokens for users or services to the database"""
|
||||
if kind == 'user':
|
||||
@@ -1995,12 +2200,13 @@ class JupyterHub(Application):
|
||||
run periodically
|
||||
"""
|
||||
# this should be all the subclasses of Expiring
|
||||
for cls in (orm.APIToken, orm.OAuthAccessToken, orm.OAuthCode):
|
||||
for cls in (orm.APIToken, orm.OAuthCode):
|
||||
self.log.debug("Purging expired {name}s".format(name=cls.__name__))
|
||||
cls.purge_expired(self.db)
|
||||
|
||||
async def init_api_tokens(self):
|
||||
"""Load predefined API tokens (for services) into database"""
|
||||
|
||||
await self._add_tokens(self.service_tokens, kind='service')
|
||||
await self._add_tokens(self.api_tokens, kind='user')
|
||||
|
||||
@@ -2008,6 +2214,7 @@ class JupyterHub(Application):
|
||||
# purge expired tokens hourly
|
||||
# we don't need to be prompt about this
|
||||
# because expired tokens cannot be used anyway
|
||||
|
||||
pc = PeriodicCallback(
|
||||
self.purge_expired_tokens, 1e3 * self.purge_expired_tokens_interval
|
||||
)
|
||||
@@ -2031,6 +2238,14 @@ class JupyterHub(Application):
|
||||
if orm_service is None:
|
||||
# not found, create a new one
|
||||
orm_service = orm.Service(name=name)
|
||||
if spec.get('admin', False):
|
||||
self.log.warning(
|
||||
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
|
||||
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
|
||||
" If you specify services in the admin role configuration, "
|
||||
"the Service admin flag will be ignored."
|
||||
)
|
||||
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
|
||||
self.db.add(orm_service)
|
||||
orm_service.admin = spec.get('admin', False)
|
||||
self.db.commit()
|
||||
@@ -2040,6 +2255,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,
|
||||
@@ -2051,18 +2267,14 @@ class JupyterHub(Application):
|
||||
raise AttributeError("No such service field: %s" % key)
|
||||
setattr(service, key, value)
|
||||
|
||||
if service.managed:
|
||||
if not service.api_token:
|
||||
if service.api_token:
|
||||
self.service_tokens[service.api_token] = service.name
|
||||
elif service.managed:
|
||||
# generate new token
|
||||
# TODO: revoke old tokens?
|
||||
service.api_token = service.orm.new_api_token(
|
||||
note="generated at startup"
|
||||
)
|
||||
else:
|
||||
# ensure provided token is registered
|
||||
self.service_tokens[service.api_token] = service.name
|
||||
else:
|
||||
self.service_tokens[service.api_token] = service.name
|
||||
|
||||
if service.url:
|
||||
parsed = urlparse(service.url)
|
||||
@@ -2085,12 +2297,24 @@ class JupyterHub(Application):
|
||||
service.orm.server = None
|
||||
|
||||
if service.oauth_available:
|
||||
self.oauth_provider.add_client(
|
||||
allowed_roles = []
|
||||
if service.oauth_roles:
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(
|
||||
orm.Role.name.in_(service.oauth_roles)
|
||||
)
|
||||
)
|
||||
oauth_client = self.oauth_provider.add_client(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
allowed_roles=allowed_roles,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
service.orm.oauth_client = oauth_client
|
||||
else:
|
||||
if service.oauth_client:
|
||||
self.db.delete(service.oauth_client)
|
||||
|
||||
self._service_map[name] = service
|
||||
|
||||
@@ -2106,7 +2330,7 @@ class JupyterHub(Application):
|
||||
if not service.url:
|
||||
continue
|
||||
try:
|
||||
await Server.from_orm(service.orm.server).wait_up(timeout=1)
|
||||
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
||||
except TimeoutError:
|
||||
self.log.warning(
|
||||
"Cannot connect to %s service %s at %s",
|
||||
@@ -2276,7 +2500,7 @@ class JupyterHub(Application):
|
||||
|
||||
This should mainly be services that have been removed from configuration or renamed.
|
||||
"""
|
||||
oauth_client_ids = set()
|
||||
oauth_client_ids = {"jupyterhub"}
|
||||
for service in self._service_map.values():
|
||||
if service.oauth_available:
|
||||
oauth_client_ids.add(service.oauth_client_id)
|
||||
@@ -2511,10 +2735,12 @@ class JupyterHub(Application):
|
||||
self.init_hub()
|
||||
self.init_proxy()
|
||||
self.init_oauth()
|
||||
await self.init_role_creation()
|
||||
await self.init_users()
|
||||
await self.init_groups()
|
||||
self.init_services()
|
||||
await self.init_api_tokens()
|
||||
await self.init_role_assignment()
|
||||
self.init_tornado_settings()
|
||||
self.init_handlers()
|
||||
self.init_tornado_application()
|
||||
@@ -2797,7 +3023,7 @@ class JupyterHub(Application):
|
||||
if service.managed:
|
||||
self.log.info("Starting managed service %s", msg)
|
||||
try:
|
||||
service.start()
|
||||
await service.start()
|
||||
except Exception as e:
|
||||
self.log.critical(
|
||||
"Failed to start service %s", service_name, exc_info=True
|
||||
@@ -2810,11 +3036,6 @@ class JupyterHub(Application):
|
||||
tries = 10 if service.managed else 1
|
||||
for i in range(tries):
|
||||
try:
|
||||
ssl_context = make_ssl_context(
|
||||
self.internal_ssl_key,
|
||||
self.internal_ssl_cert,
|
||||
cafile=self.internal_ssl_ca,
|
||||
)
|
||||
await Server.from_orm(service.orm.server).wait_up(
|
||||
http=True, timeout=1, ssl_context=ssl_context
|
||||
)
|
||||
|
@@ -112,7 +112,7 @@ class Authenticator(LoggingConfigurable):
|
||||
|
||||
Use this with supported authenticators to restrict which users can log in. This is an
|
||||
additional list that further restricts users, beyond whatever restrictions the
|
||||
authenticator has in place.
|
||||
authenticator has in place. Any user in this list is granted the 'user' role on hub startup.
|
||||
|
||||
If empty, does not perform any additional restriction.
|
||||
|
||||
|
@@ -136,7 +136,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()
|
||||
|
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
@@ -30,6 +31,8 @@ from tornado.web import RequestHandler
|
||||
|
||||
from .. import __version__
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from .. import scopes
|
||||
from ..metrics import PROXY_ADD_DURATION_SECONDS
|
||||
from ..metrics import PROXY_DELETE_DURATION_SECONDS
|
||||
from ..metrics import ProxyDeleteStatus
|
||||
@@ -79,12 +82,13 @@ class BaseHandler(RequestHandler):
|
||||
The current user (None if not logged in) may be accessed
|
||||
via the `self.current_user` property during the handling of any request.
|
||||
"""
|
||||
self.expanded_scopes = set()
|
||||
try:
|
||||
await self.get_current_user()
|
||||
except Exception:
|
||||
self.log.exception("Failed to get current user")
|
||||
self._jupyterhub_user = None
|
||||
|
||||
self._resolve_roles_and_scopes()
|
||||
return await maybe_future(super().prepare())
|
||||
|
||||
@property
|
||||
@@ -244,26 +248,6 @@ class BaseHandler(RequestHandler):
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
def get_current_user_oauth_token(self):
|
||||
"""Get the current user identified by OAuth access token
|
||||
|
||||
Separate from API token because OAuth access tokens
|
||||
can only be used for identifying users,
|
||||
not using the API.
|
||||
"""
|
||||
token = self.get_auth_token()
|
||||
if token is None:
|
||||
return None
|
||||
orm_token = orm.OAuthAccessToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
recorded = self._record_activity(orm_token, now)
|
||||
if self._record_activity(orm_token.user, now) or recorded:
|
||||
self.db.commit()
|
||||
return self._user_from_orm(orm_token.user)
|
||||
|
||||
def _record_activity(self, obj, timestamp=None):
|
||||
"""record activity on an ORM object
|
||||
|
||||
@@ -350,23 +334,27 @@ class BaseHandler(RequestHandler):
|
||||
auth_info['auth_state'] = await user.get_auth_state()
|
||||
return await self.auth_to_user(auth_info, user)
|
||||
|
||||
def get_current_user_token(self):
|
||||
"""get_current_user from Authorization header token"""
|
||||
def get_token(self):
|
||||
"""get token from authorization header"""
|
||||
token = self.get_auth_token()
|
||||
if token is None:
|
||||
return None
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
return orm_token
|
||||
|
||||
def get_current_user_token(self):
|
||||
"""get_current_user from Authorization header token"""
|
||||
# record token activity
|
||||
orm_token = self.get_token()
|
||||
if orm_token is None:
|
||||
return None
|
||||
|
||||
# record token activity
|
||||
now = datetime.utcnow()
|
||||
recorded = self._record_activity(orm_token, now)
|
||||
if orm_token.user:
|
||||
# FIXME: scopes should give us better control than this
|
||||
# don't consider API requests originating from a server
|
||||
# to be activity from the user
|
||||
if not orm_token.note.startswith("Server at "):
|
||||
if not orm_token.note or not orm_token.note.startswith("Server at "):
|
||||
recorded = self._record_activity(orm_token.user, now) or recorded
|
||||
if recorded:
|
||||
self.db.commit()
|
||||
@@ -429,6 +417,36 @@ class BaseHandler(RequestHandler):
|
||||
self.log.exception("Error getting current user")
|
||||
return self._jupyterhub_user
|
||||
|
||||
def _resolve_roles_and_scopes(self):
|
||||
self.expanded_scopes = set()
|
||||
app_log.debug("Loading and parsing scopes")
|
||||
if self.current_user:
|
||||
orm_token = self.get_token()
|
||||
if orm_token:
|
||||
self.expanded_scopes = scopes.get_scopes_for(orm_token)
|
||||
else:
|
||||
self.expanded_scopes = scopes.get_scopes_for(self.current_user)
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
app_log.debug("Found scopes [%s]", ",".join(self.expanded_scopes))
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_scope_filter(self, req_scope):
|
||||
"""Produce a filter function for req_scope on resources
|
||||
|
||||
Returns `has_access_to(orm_resource, kind)` which returns True or False
|
||||
for whether the current request has access to req_scope on the given resource.
|
||||
"""
|
||||
|
||||
def no_access(orm_resource, kind):
|
||||
return False
|
||||
|
||||
if req_scope not in self.parsed_scopes:
|
||||
return no_access
|
||||
|
||||
sub_scope = self.parsed_scopes[req_scope]
|
||||
|
||||
return functools.partial(scopes.check_scope_filter, sub_scope)
|
||||
|
||||
@property
|
||||
def current_user(self):
|
||||
"""Override .current_user accessor from tornado
|
||||
@@ -454,6 +472,7 @@ class BaseHandler(RequestHandler):
|
||||
# not found, create and register user
|
||||
u = orm.User(name=username)
|
||||
self.db.add(u)
|
||||
roles.assign_default_roles(self.db, entity=u)
|
||||
TOTAL_USERS.inc()
|
||||
self.db.commit()
|
||||
user = self._user_from_orm(u)
|
||||
@@ -474,10 +493,8 @@ class BaseHandler(RequestHandler):
|
||||
# don't clear session tokens if not logged in,
|
||||
# because that could be a malicious logout request!
|
||||
count = 0
|
||||
for access_token in (
|
||||
self.db.query(orm.OAuthAccessToken)
|
||||
.filter(orm.OAuthAccessToken.user_id == user.id)
|
||||
.filter(orm.OAuthAccessToken.session_id == session_id)
|
||||
for access_token in self.db.query(orm.APIToken).filter_by(
|
||||
user_id=user.id, session_id=session_id
|
||||
):
|
||||
self.db.delete(access_token)
|
||||
count += 1
|
||||
@@ -738,6 +755,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.assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
# always set auth_state and commit,
|
||||
# because there could be key-rotation or clearing of previous values
|
||||
@@ -983,6 +1001,7 @@ class BaseHandler(RequestHandler):
|
||||
self.log.critical(
|
||||
"Aborting due to %i consecutive spawn failures", failure_count
|
||||
)
|
||||
|
||||
# abort in 2 seconds to allow pending handlers to resolve
|
||||
# mostly propagating errors for the current failures
|
||||
def abort():
|
||||
|
@@ -10,14 +10,13 @@ from http.client import responses
|
||||
from jinja2 import TemplateNotFound
|
||||
from tornado import web
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.httputil import urlparse
|
||||
|
||||
from .. import __version__
|
||||
from .. import orm
|
||||
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
||||
from ..metrics import ServerPollStatus
|
||||
from ..pagination import Pagination
|
||||
from ..utils import admin_only
|
||||
from ..scopes import needs_scope
|
||||
from ..utils import maybe_future
|
||||
from ..utils import url_path_join
|
||||
from .base import BaseHandler
|
||||
@@ -455,7 +454,9 @@ class AdminHandler(BaseHandler):
|
||||
"""Render the admin page."""
|
||||
|
||||
@web.authenticated
|
||||
@admin_only
|
||||
@needs_scope('users') # stacked decorators: all scopes must be present
|
||||
@needs_scope('admin:users')
|
||||
@needs_scope('admin:servers')
|
||||
async def get(self):
|
||||
auth_state = await self.current_user.get_auth_state()
|
||||
html = await self.render_template(
|
||||
@@ -484,36 +485,32 @@ class TokenPageHandler(BaseHandler):
|
||||
return (token.last_activity or never, token.created or never)
|
||||
|
||||
now = datetime.utcnow()
|
||||
api_tokens = []
|
||||
for token in sorted(user.api_tokens, key=sort_key, reverse=True):
|
||||
if token.expires_at and token.expires_at < now:
|
||||
self.db.delete(token)
|
||||
self.db.commit()
|
||||
continue
|
||||
api_tokens.append(token)
|
||||
|
||||
# group oauth client tokens by client id
|
||||
# AccessTokens have expires_at as an integer timestamp
|
||||
now_timestamp = now.timestamp()
|
||||
oauth_tokens = defaultdict(list)
|
||||
for token in user.oauth_tokens:
|
||||
if token.expires_at and token.expires_at < now_timestamp:
|
||||
self.log.warning("Deleting expired token")
|
||||
all_tokens = defaultdict(list)
|
||||
for token in sorted(user.api_tokens, key=sort_key, reverse=True):
|
||||
if token.expires_at and token.expires_at < now:
|
||||
self.log.warning(f"Deleting expired token {token}")
|
||||
self.db.delete(token)
|
||||
self.db.commit()
|
||||
continue
|
||||
if not token.client_id:
|
||||
# token should have been deleted when client was deleted
|
||||
self.log.warning("Deleting stale oauth token for %s", user.name)
|
||||
self.log.warning("Deleting stale oauth token {token}")
|
||||
self.db.delete(token)
|
||||
self.db.commit()
|
||||
continue
|
||||
oauth_tokens[token.client_id].append(token)
|
||||
all_tokens[token.client_id].append(token)
|
||||
|
||||
# individually list tokens issued by jupyterhub itself
|
||||
api_tokens = all_tokens.pop("jupyterhub", [])
|
||||
|
||||
# group all other tokens issued under their owners
|
||||
# get the earliest created and latest last_activity
|
||||
# timestamp for a given oauth client
|
||||
oauth_clients = []
|
||||
for client_id, tokens in oauth_tokens.items():
|
||||
|
||||
for client_id, tokens in all_tokens.items():
|
||||
created = tokens[0].created
|
||||
last_activity = tokens[0].last_activity
|
||||
for token in tokens[1:]:
|
||||
@@ -526,8 +523,9 @@ class TokenPageHandler(BaseHandler):
|
||||
token = tokens[0]
|
||||
oauth_clients.append(
|
||||
{
|
||||
'client': token.client,
|
||||
'description': token.client.description or token.client.identifier,
|
||||
'client': token.oauth_client,
|
||||
'description': token.oauth_client.description
|
||||
or token.oauth_client.identifier,
|
||||
'created': created,
|
||||
'last_activity': last_activity,
|
||||
'tokens': tokens,
|
||||
|
@@ -7,13 +7,11 @@ from oauthlib.oauth2 import RequestValidator
|
||||
from oauthlib.oauth2 import WebApplicationServer
|
||||
from oauthlib.oauth2.rfc6749.grant_types import authorization_code
|
||||
from oauthlib.oauth2.rfc6749.grant_types import base
|
||||
from tornado.escape import url_escape
|
||||
from tornado.log import app_log
|
||||
|
||||
from .. import orm
|
||||
from ..utils import compare_token
|
||||
from ..utils import hash_token
|
||||
from ..utils import url_path_join
|
||||
|
||||
# patch absolute-uri check
|
||||
# because we want to allow relative uri oauth
|
||||
@@ -60,6 +58,9 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
)
|
||||
if oauth_client is None:
|
||||
return False
|
||||
if not client_secret or not oauth_client.secret:
|
||||
# disallow authentication with no secret
|
||||
return False
|
||||
if not compare_token(oauth_client.secret, client_secret):
|
||||
app_log.warning("Client secret mismatch for %s", client_id)
|
||||
return False
|
||||
@@ -146,7 +147,12 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials grant
|
||||
"""
|
||||
return ['identify']
|
||||
orm_client = (
|
||||
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
|
||||
)
|
||||
if orm_client is None:
|
||||
raise ValueError("No such client: %s" % client_id)
|
||||
return [role.name for role in orm_client.allowed_roles]
|
||||
|
||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||
"""Get the list of scopes associated with the refresh token.
|
||||
@@ -246,8 +252,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
code=code['code'],
|
||||
# oauth has 5 minutes to complete
|
||||
expires_at=int(orm.OAuthCode.now() + 300),
|
||||
# TODO: persist oauth scopes
|
||||
# scopes=request.scopes,
|
||||
roles=request._jupyterhub_roles,
|
||||
user=request.user.orm_user,
|
||||
redirect_uri=orm_client.redirect_uri,
|
||||
session_id=request.session_id,
|
||||
@@ -321,10 +326,6 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
"""
|
||||
log_token = {}
|
||||
log_token.update(token)
|
||||
scopes = token['scope'].split(' ')
|
||||
# TODO:
|
||||
if scopes != ['identify']:
|
||||
raise ValueError("Only 'identify' scope is supported")
|
||||
# redact sensitive keys in log
|
||||
for key in ('access_token', 'refresh_token', 'state'):
|
||||
if key in token:
|
||||
@@ -332,6 +333,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
if isinstance(value, str):
|
||||
log_token[key] = 'REDACTED'
|
||||
app_log.debug("Saving bearer token %s", log_token)
|
||||
|
||||
if request.user is None:
|
||||
raise ValueError("No user for access token: %s" % request.user)
|
||||
client = (
|
||||
@@ -339,19 +341,19 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
.filter_by(identifier=request.client.client_id)
|
||||
.first()
|
||||
)
|
||||
orm_access_token = orm.OAuthAccessToken(
|
||||
client=client,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
expires_at=int(orm.OAuthAccessToken.now() + token['expires_in']),
|
||||
refresh_token=token['refresh_token'],
|
||||
# TODO: save scopes,
|
||||
# scopes=scopes,
|
||||
# FIXME: support refresh tokens
|
||||
# These should be in a new table
|
||||
token.pop("refresh_token", None)
|
||||
|
||||
# APIToken.new commits the token to the db
|
||||
orm.APIToken.new(
|
||||
client_id=client.identifier,
|
||||
expires_in=token['expires_in'],
|
||||
roles=[rolename for rolename in request.scopes],
|
||||
token=token['access_token'],
|
||||
session_id=request.session_id,
|
||||
user=request.user,
|
||||
)
|
||||
self.db.add(orm_access_token)
|
||||
self.db.commit()
|
||||
return client.redirect_uri
|
||||
|
||||
def validate_bearer_token(self, token, scopes, request):
|
||||
@@ -412,6 +414,8 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
)
|
||||
if orm_client is None:
|
||||
return False
|
||||
if not orm_client.secret:
|
||||
return False
|
||||
request.client = orm_client
|
||||
return True
|
||||
|
||||
@@ -447,9 +451,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
return False
|
||||
request.user = orm_code.user
|
||||
request.session_id = orm_code.session_id
|
||||
# TODO: record state on oauth codes
|
||||
# TODO: specify scopes
|
||||
request.scopes = ['identify']
|
||||
request.scopes = [role.name for role in orm_code.roles]
|
||||
return True
|
||||
|
||||
def validate_grant_type(
|
||||
@@ -545,6 +547,35 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials Grant
|
||||
"""
|
||||
orm_client = (
|
||||
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none()
|
||||
)
|
||||
if orm_client is None:
|
||||
app_log.warning("No such oauth client %s", client_id)
|
||||
return False
|
||||
client_allowed_roles = {role.name: role for role in orm_client.allowed_roles}
|
||||
# explicitly allow 'identify', which was the only allowed scope previously
|
||||
# requesting 'identify' gets no actual permissions other than self-identification
|
||||
client_allowed_roles.setdefault('identify', None)
|
||||
roles = []
|
||||
requested_roles = set(scopes)
|
||||
disallowed_roles = requested_roles.difference(client_allowed_roles)
|
||||
if disallowed_roles:
|
||||
app_log.error(
|
||||
f"Role(s) not allowed for {client_id}: {','.join(disallowed_roles)}"
|
||||
)
|
||||
return False
|
||||
|
||||
# store resolved roles on request
|
||||
app_log.debug(
|
||||
f"Allowing request for role(s) for {client_id}: {','.join(requested_roles) or '[]'}"
|
||||
)
|
||||
# these will be stored on the OAuthCode object
|
||||
request._jupyterhub_roles = [
|
||||
client_allowed_roles[name]
|
||||
for name in requested_roles
|
||||
if client_allowed_roles[name] is not None
|
||||
]
|
||||
return True
|
||||
|
||||
|
||||
@@ -553,7 +584,9 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
self.db = db
|
||||
super().__init__(validator, *args, **kwargs)
|
||||
|
||||
def add_client(self, client_id, client_secret, redirect_uri, description=''):
|
||||
def add_client(
|
||||
self, client_id, client_secret, redirect_uri, allowed_roles=None, description=''
|
||||
):
|
||||
"""Add a client
|
||||
|
||||
hash its client_secret before putting it in the database.
|
||||
@@ -574,14 +607,20 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
app_log.info(f'Creating oauth client {client_id}')
|
||||
else:
|
||||
app_log.info(f'Updating oauth client {client_id}')
|
||||
orm_client.secret = hash_token(client_secret)
|
||||
if allowed_roles == None:
|
||||
allowed_roles = []
|
||||
orm_client.secret = hash_token(client_secret) if client_secret else ""
|
||||
orm_client.redirect_uri = redirect_uri
|
||||
orm_client.description = description
|
||||
orm_client.description = description or client_id
|
||||
orm_client.allowed_roles = allowed_roles
|
||||
self.db.commit()
|
||||
return orm_client
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
"""Find a client by its id"""
|
||||
return self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
|
||||
client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
|
||||
if client and client.secret:
|
||||
return client
|
||||
|
||||
|
||||
def make_provider(session_factory, url_prefix, login_url, **oauth_server_kwargs):
|
||||
|
@@ -3,6 +3,7 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import enum
|
||||
import json
|
||||
import warnings
|
||||
from base64 import decodebytes
|
||||
from base64 import encodebytes
|
||||
from datetime import datetime
|
||||
@@ -15,12 +16,12 @@ from sqlalchemy import Boolean
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import Enum
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy import exc
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import Table
|
||||
@@ -39,6 +40,10 @@ from sqlalchemy.types import Text
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from tornado.log import app_log
|
||||
|
||||
from .roles import assign_default_roles
|
||||
from .roles import create_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
|
||||
@@ -90,7 +95,37 @@ class JSONDict(TypeDecorator):
|
||||
return value
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
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
|
||||
|
||||
|
||||
meta = MetaData(
|
||||
naming_convention={
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
)
|
||||
|
||||
Base = declarative_base(metadata=meta)
|
||||
Base.log = app_log
|
||||
|
||||
|
||||
@@ -113,6 +148,65 @@ class Server(Base):
|
||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||
|
||||
|
||||
# lots of things have roles
|
||||
# mapping tables are the same for all of them
|
||||
|
||||
_role_map_tables = []
|
||||
|
||||
for has_role in (
|
||||
'user',
|
||||
'group',
|
||||
'service',
|
||||
'api_token',
|
||||
'oauth_client',
|
||||
'oauth_code',
|
||||
):
|
||||
role_map = Table(
|
||||
f'{has_role}_role_map',
|
||||
Base.metadata,
|
||||
Column(
|
||||
f'{has_role}_id',
|
||||
ForeignKey(f'{has_role}s.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
'role_id',
|
||||
ForeignKey('roles.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
_role_map_tables.append(role_map)
|
||||
|
||||
|
||||
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')
|
||||
services = relationship('Service', secondary='service_role_map', backref='roles')
|
||||
tokens = relationship('APIToken', secondary='api_token_role_map', backref='roles')
|
||||
groups = relationship('Group', secondary='group_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',
|
||||
@@ -180,14 +274,11 @@ class User(Base):
|
||||
def orm_spawners(self):
|
||||
return {s.name: s for s in self._orm_spawners}
|
||||
|
||||
admin = Column(Boolean, default=False)
|
||||
admin = Column(Boolean(create_constraint=False), default=False)
|
||||
created = Column(DateTime, default=datetime.utcnow)
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user", cascade="all, delete-orphan")
|
||||
oauth_tokens = relationship(
|
||||
"OAuthAccessToken", backref="user", cascade="all, delete-orphan"
|
||||
)
|
||||
oauth_codes = relationship(
|
||||
"OAuthCode", backref="user", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -223,7 +314,7 @@ class User(Base):
|
||||
|
||||
|
||||
class Spawner(Base):
|
||||
""""State about a Spawner"""
|
||||
""" "State about a Spawner"""
|
||||
|
||||
__tablename__ = 'spawners'
|
||||
|
||||
@@ -245,6 +336,21 @@ class Spawner(Base):
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
user_options = Column(JSONDict)
|
||||
|
||||
# added in 2.0
|
||||
oauth_client_id = Column(
|
||||
Unicode(255),
|
||||
ForeignKey(
|
||||
'oauth_clients.identifier',
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
)
|
||||
oauth_client = relationship(
|
||||
'OAuthClient',
|
||||
backref=backref("spawner", uselist=False),
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
|
||||
# properties on the spawner wrapper
|
||||
# some APIs get these low-level objects
|
||||
# when the spawner isn't running,
|
||||
@@ -280,7 +386,7 @@ class Service(Base):
|
||||
|
||||
# common user interface:
|
||||
name = Column(Unicode(255), unique=True)
|
||||
admin = Column(Boolean, default=False)
|
||||
admin = Column(Boolean(create_constraint=False), default=False)
|
||||
|
||||
api_tokens = relationship(
|
||||
"APIToken", backref="service", cascade="all, delete-orphan"
|
||||
@@ -296,6 +402,21 @@ class Service(Base):
|
||||
)
|
||||
pid = Column(Integer)
|
||||
|
||||
# added in 2.0
|
||||
oauth_client_id = Column(
|
||||
Unicode(255),
|
||||
ForeignKey(
|
||||
'oauth_clients.identifier',
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
)
|
||||
oauth_client = relationship(
|
||||
'OAuthClient',
|
||||
backref=backref("service", uselist=False),
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
|
||||
def new_api_token(self, token=None, **kwargs):
|
||||
"""Create a new API token
|
||||
If `token` is given, load that token.
|
||||
@@ -438,14 +559,34 @@ class Hashed(Expiring):
|
||||
return orm_token
|
||||
|
||||
|
||||
# ------------------------------------
|
||||
# OAuth tables
|
||||
# ------------------------------------
|
||||
|
||||
|
||||
class GrantType(enum.Enum):
|
||||
# we only use authorization_code for now
|
||||
authorization_code = 'authorization_code'
|
||||
implicit = 'implicit'
|
||||
password = 'password'
|
||||
client_credentials = 'client_credentials'
|
||||
refresh_token = 'refresh_token'
|
||||
|
||||
|
||||
class APIToken(Hashed, Base):
|
||||
"""An API token"""
|
||||
|
||||
__tablename__ = 'api_tokens'
|
||||
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey('users.id', ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
service_id = Column(
|
||||
Integer, ForeignKey('services.id', ondelete="CASCADE"), nullable=True
|
||||
Integer,
|
||||
ForeignKey('services.id', ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -456,6 +597,27 @@ class APIToken(Hashed, Base):
|
||||
def api_id(self):
|
||||
return 'a%i' % self.id
|
||||
|
||||
# added in 2.0
|
||||
client_id = Column(
|
||||
Unicode(255),
|
||||
ForeignKey(
|
||||
'oauth_clients.identifier',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
)
|
||||
|
||||
# FIXME: refresh_tokens not implemented
|
||||
# should be a relation to another token table
|
||||
# refresh_token = Column(
|
||||
# Integer,
|
||||
# ForeignKey('refresh_tokens.id', ondelete="CASCADE"),
|
||||
# nullable=True,
|
||||
# )
|
||||
|
||||
# the browser session id associated with a given token,
|
||||
# if issued during oauth to be stored in a cookie
|
||||
session_id = Column(Unicode(255), nullable=True)
|
||||
|
||||
# token metadata for bookkeeping
|
||||
now = datetime.utcnow # for expiry
|
||||
created = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -474,8 +636,12 @@ class APIToken(Hashed, Base):
|
||||
# this shouldn't happen
|
||||
kind = 'owner'
|
||||
name = 'unknown'
|
||||
return "<{cls}('{pre}...', {kind}='{name}')>".format(
|
||||
cls=self.__class__.__name__, pre=self.prefix, kind=kind, name=name
|
||||
return "<{cls}('{pre}...', {kind}='{name}', client_id={client_id!r})>".format(
|
||||
cls=self.__class__.__name__,
|
||||
pre=self.prefix,
|
||||
kind=kind,
|
||||
name=name,
|
||||
client_id=self.client_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -496,6 +662,14 @@ class APIToken(Hashed, Base):
|
||||
raise ValueError("kind must be 'user', 'service', or None, not %r" % kind)
|
||||
for orm_token in prefix_match:
|
||||
if orm_token.match(token):
|
||||
if not orm_token.client_id:
|
||||
app_log.warning(
|
||||
"Deleting stale oauth token for %s with no client",
|
||||
orm_token.user and orm_token.user.name,
|
||||
)
|
||||
db.delete(orm_token)
|
||||
db.commit()
|
||||
return
|
||||
return orm_token
|
||||
|
||||
@classmethod
|
||||
@@ -504,9 +678,13 @@ class APIToken(Hashed, Base):
|
||||
token=None,
|
||||
user=None,
|
||||
service=None,
|
||||
roles=None,
|
||||
note='',
|
||||
generated=True,
|
||||
session_id=None,
|
||||
expires_in=None,
|
||||
client_id='jupyterhub',
|
||||
return_orm=False,
|
||||
):
|
||||
"""Generate a new API token for a user or service"""
|
||||
assert user or service
|
||||
@@ -521,7 +699,12 @@ class APIToken(Hashed, Base):
|
||||
cls.check_token(db, token)
|
||||
# two stages to ensure orm_token.generated has been set
|
||||
# before token setter is called
|
||||
orm_token = cls(generated=generated, note=note or '')
|
||||
orm_token = cls(
|
||||
generated=generated,
|
||||
note=note or '',
|
||||
client_id=client_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
orm_token.token = token
|
||||
if user:
|
||||
assert user.id is not None
|
||||
@@ -531,79 +714,22 @@ class APIToken(Hashed, Base):
|
||||
orm_token.service = service
|
||||
if expires_in is not None:
|
||||
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
|
||||
|
||||
db.add(orm_token)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
# ------------------------------------
|
||||
# OAuth tables
|
||||
# ------------------------------------
|
||||
|
||||
|
||||
class GrantType(enum.Enum):
|
||||
# we only use authorization_code for now
|
||||
authorization_code = 'authorization_code'
|
||||
implicit = 'implicit'
|
||||
password = 'password'
|
||||
client_credentials = 'client_credentials'
|
||||
refresh_token = 'refresh_token'
|
||||
|
||||
|
||||
class OAuthAccessToken(Hashed, Base):
|
||||
__tablename__ = 'oauth_access_tokens'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@staticmethod
|
||||
def now():
|
||||
return datetime.utcnow().timestamp()
|
||||
|
||||
@property
|
||||
def api_id(self):
|
||||
return 'o%i' % self.id
|
||||
|
||||
client_id = Column(
|
||||
Unicode(255), ForeignKey('oauth_clients.identifier', ondelete='CASCADE')
|
||||
)
|
||||
grant_type = Column(Enum(GrantType), nullable=False)
|
||||
expires_at = Column(Integer)
|
||||
refresh_token = Column(Unicode(255))
|
||||
# TODO: drop refresh_expires_at. Refresh tokens shouldn't expire
|
||||
refresh_expires_at = Column(Integer)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
service = None # for API-equivalence with APIToken
|
||||
|
||||
# the browser session id associated with a given token
|
||||
session_id = Column(Unicode(255))
|
||||
|
||||
# from Hashed
|
||||
hashed = Column(Unicode(255), unique=True)
|
||||
prefix = Column(Unicode(16), index=True)
|
||||
|
||||
created = Column(DateTime, default=datetime.utcnow)
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{cls}('{prefix}...', client_id={client_id!r}, user={user!r}, expires_in={expires_in}>".format(
|
||||
cls=self.__class__.__name__,
|
||||
client_id=self.client_id,
|
||||
user=self.user and self.user.name,
|
||||
prefix=self.prefix,
|
||||
expires_in=self.expires_in,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, token):
|
||||
orm_token = super().find(db, token)
|
||||
if orm_token and not orm_token.client_id:
|
||||
app_log.warning(
|
||||
"Deleting stale oauth token for %s with no client",
|
||||
orm_token.user and orm_token.user.name,
|
||||
)
|
||||
if not Role.find(db, 'token'):
|
||||
raise RuntimeError("Default token role has not been created")
|
||||
try:
|
||||
if roles is not None:
|
||||
update_roles(db, entity=orm_token, roles=roles)
|
||||
else:
|
||||
assign_default_roles(db, entity=orm_token)
|
||||
except Exception:
|
||||
db.delete(orm_token)
|
||||
db.commit()
|
||||
return
|
||||
return orm_token
|
||||
raise
|
||||
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
class OAuthCode(Expiring, Base):
|
||||
@@ -620,6 +746,8 @@ class OAuthCode(Expiring, Base):
|
||||
# state = Column(Unicode(1023))
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
roles = relationship('Role', secondary='oauth_code_role_map')
|
||||
|
||||
@staticmethod
|
||||
def now():
|
||||
return datetime.utcnow().timestamp()
|
||||
@@ -633,6 +761,11 @@ class OAuthCode(Expiring, Base):
|
||||
.first()
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<{self.__class__.__name__}(id={self.id}, client_id={self.client_id!r})>"
|
||||
)
|
||||
|
||||
|
||||
class OAuthClient(Base):
|
||||
__tablename__ = 'oauth_clients'
|
||||
@@ -647,10 +780,17 @@ class OAuthClient(Base):
|
||||
return self.identifier
|
||||
|
||||
access_tokens = relationship(
|
||||
OAuthAccessToken, backref='client', cascade='all, delete-orphan'
|
||||
APIToken, backref='oauth_client', cascade='all, delete-orphan'
|
||||
)
|
||||
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
||||
|
||||
# these are the roles an oauth client is allowed to request
|
||||
# *not* the roles of the client itself
|
||||
allowed_roles = relationship('Role', secondary='oauth_client_role_map')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}(identifier={self.identifier!r})>"
|
||||
|
||||
|
||||
# General database utilities
|
||||
|
||||
@@ -887,3 +1027,18 @@ def new_session_factory(
|
||||
# this off gives us a major performance boost
|
||||
session_factory = sessionmaker(bind=engine, expire_on_commit=expire_on_commit)
|
||||
return session_factory
|
||||
|
||||
|
||||
def get_class(resource_name):
|
||||
"""Translates resource string names to ORM classes"""
|
||||
class_dict = {
|
||||
'users': User,
|
||||
'services': Service,
|
||||
'tokens': APIToken,
|
||||
'groups': Group,
|
||||
}
|
||||
if resource_name not in class_dict:
|
||||
raise ValueError(
|
||||
"Kind must be one of %s, not %s" % (", ".join(class_dict), resource_name)
|
||||
)
|
||||
return class_dict[resource_name]
|
||||
|
512
jupyterhub/roles.py
Normal file
512
jupyterhub/roles.py
Normal file
@@ -0,0 +1,512 @@
|
||||
"""Roles utils"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import re
|
||||
from itertools import chain
|
||||
|
||||
from sqlalchemy import func
|
||||
from tornado.log import app_log
|
||||
|
||||
from . import orm
|
||||
from . import scopes
|
||||
|
||||
|
||||
def get_default_roles():
|
||||
"""Returns:
|
||||
default roles (list): default role definitions as dictionaries:
|
||||
{
|
||||
'name': role name,
|
||||
'description': role description,
|
||||
'scopes': list of scopes,
|
||||
}
|
||||
"""
|
||||
default_roles = [
|
||||
{
|
||||
'name': 'user',
|
||||
'description': 'Standard user privileges',
|
||||
'scopes': [
|
||||
'self',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'admin',
|
||||
'description': 'Elevated privileges (can do anything)',
|
||||
'scopes': [
|
||||
'admin:users',
|
||||
'admin:servers',
|
||||
'tokens',
|
||||
'admin:groups',
|
||||
'read:services',
|
||||
'read:hub',
|
||||
'proxy',
|
||||
'shutdown',
|
||||
'access:services',
|
||||
'access:servers',
|
||||
'read:roles',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'server',
|
||||
'description': 'Post activity only',
|
||||
'scopes': [
|
||||
'users:activity!user',
|
||||
'access:servers!user',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'token',
|
||||
'description': 'Token with same permissions as its owner',
|
||||
'scopes': ['all'],
|
||||
},
|
||||
]
|
||||
return default_roles
|
||||
|
||||
|
||||
def expand_self_scope(name):
|
||||
"""
|
||||
Users have a metascope 'self' that should be expanded to standard user privileges.
|
||||
At the moment that is a user-filtered version (optional read) access to
|
||||
users
|
||||
users:name
|
||||
users:groups
|
||||
users:activity
|
||||
tokens
|
||||
servers
|
||||
access:servers
|
||||
|
||||
|
||||
Arguments:
|
||||
name (str): user name
|
||||
|
||||
Returns:
|
||||
expanded scopes (set): set of expanded scopes covering standard user privileges
|
||||
"""
|
||||
scope_list = [
|
||||
'users',
|
||||
'read:users',
|
||||
'read:users:name',
|
||||
'read:users:groups',
|
||||
'users:activity',
|
||||
'read:users:activity',
|
||||
'servers',
|
||||
'read:servers',
|
||||
'tokens',
|
||||
'read:tokens',
|
||||
'access:servers',
|
||||
]
|
||||
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
||||
|
||||
|
||||
def horizontal_filter(func):
|
||||
"""Decorator to account for horizontal filtering in scope syntax"""
|
||||
|
||||
def expand_server_filter(hor_filter):
|
||||
resource, mark, value = hor_filter.partition('=')
|
||||
if resource == 'server':
|
||||
user, mark, server = value.partition('/')
|
||||
return f'read:users:name!user={user}'
|
||||
|
||||
def ignore(scopename):
|
||||
# temporarily remove horizontal filtering if present
|
||||
scopename, mark, hor_filter = scopename.partition('!')
|
||||
expanded_scope = func(scopename)
|
||||
# add the filter back
|
||||
full_expanded_scope = {scope + mark + hor_filter for scope in expanded_scope}
|
||||
server_filter = expand_server_filter(hor_filter)
|
||||
if server_filter:
|
||||
full_expanded_scope.add(server_filter)
|
||||
return full_expanded_scope
|
||||
|
||||
return ignore
|
||||
|
||||
|
||||
@horizontal_filter
|
||||
def _expand_scope(scopename):
|
||||
"""Returns a set of all subscopes
|
||||
Arguments:
|
||||
scopename (str): name of the scope to expand
|
||||
|
||||
Returns:
|
||||
expanded scope (set): set of all scope's subscopes including the scope itself
|
||||
"""
|
||||
expanded_scope = []
|
||||
|
||||
def _add_subscopes(scopename):
|
||||
expanded_scope.append(scopename)
|
||||
if scopes.scope_definitions[scopename].get('subscopes'):
|
||||
for subscope in scopes.scope_definitions[scopename].get('subscopes'):
|
||||
_add_subscopes(subscope)
|
||||
|
||||
_add_subscopes(scopename)
|
||||
|
||||
return set(expanded_scope)
|
||||
|
||||
|
||||
def expand_roles_to_scopes(orm_object):
|
||||
"""Get the scopes listed in the roles of the User/Service/Group/Token
|
||||
If User, take into account the user's groups roles as well
|
||||
|
||||
Arguments:
|
||||
orm_object: orm.User, orm.Service, orm.Group or orm.APIToken
|
||||
|
||||
Returns:
|
||||
expanded scopes (set): set of all expanded scopes for the orm object
|
||||
"""
|
||||
if not isinstance(orm_object, orm.Base):
|
||||
raise TypeError(f"Only orm objects allowed, got {orm_object}")
|
||||
|
||||
pass_roles = []
|
||||
pass_roles.extend(orm_object.roles)
|
||||
|
||||
if isinstance(orm_object, orm.User):
|
||||
for group in orm_object.groups:
|
||||
pass_roles.extend(group.roles)
|
||||
|
||||
expanded_scopes = _get_subscopes(*pass_roles, owner=orm_object)
|
||||
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def _get_subscopes(*roles, owner=None):
|
||||
"""Returns a set of all available subscopes for a specified role or list of roles
|
||||
|
||||
Arguments:
|
||||
roles (obj): orm.Roles
|
||||
owner (obj, optional): orm.User or orm.Service as owner of orm.APIToken
|
||||
|
||||
Returns:
|
||||
expanded scopes (set): set of all expanded scopes for the role(s)
|
||||
"""
|
||||
scopes = set()
|
||||
|
||||
for role in roles:
|
||||
scopes.update(role.scopes)
|
||||
|
||||
expanded_scopes = set(chain.from_iterable(list(map(_expand_scope, scopes))))
|
||||
# transform !user filter to !user=ownername
|
||||
for scope in expanded_scopes.copy():
|
||||
base_scope, _, filter = scope.partition('!')
|
||||
if filter == 'user':
|
||||
expanded_scopes.remove(scope)
|
||||
if isinstance(owner, orm.APIToken):
|
||||
token_owner = owner.user
|
||||
if token_owner is None:
|
||||
token_owner = owner.service
|
||||
name = token_owner.name
|
||||
else:
|
||||
name = owner.name
|
||||
trans_scope = f'{base_scope}!user={name}'
|
||||
expanded_scopes.add(trans_scope)
|
||||
if 'self' in expanded_scopes:
|
||||
expanded_scopes.remove('self')
|
||||
if owner and isinstance(owner, orm.User):
|
||||
expanded_scopes |= expand_self_scope(owner.name)
|
||||
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def _check_scopes(*args, rolename=None):
|
||||
"""Check if provided scopes exist
|
||||
|
||||
Arguments:
|
||||
scope (str): name of the scope to check
|
||||
or
|
||||
scopes (list): list of scopes to check
|
||||
|
||||
Raises NameError if scope does not exist
|
||||
"""
|
||||
|
||||
allowed_scopes = set(scopes.scope_definitions.keys())
|
||||
allowed_filters = ['!user=', '!service=', '!group=', '!server=', '!user']
|
||||
|
||||
if rolename:
|
||||
log_role = f"for role {rolename}"
|
||||
else:
|
||||
log_role = ""
|
||||
|
||||
for scope in args:
|
||||
scopename, _, filter_ = scope.partition('!')
|
||||
if scopename not in allowed_scopes:
|
||||
raise NameError(f"Scope '{scope}' {log_role} does not exist")
|
||||
if filter_:
|
||||
full_filter = f"!{filter_}"
|
||||
if not any(f in scope for f in allowed_filters):
|
||||
raise NameError(
|
||||
f"Scope filter '{full_filter}' in scope '{scope}' {log_role} does not exist"
|
||||
)
|
||||
|
||||
|
||||
def _overwrite_role(role, role_dict):
|
||||
"""Overwrites role's description and/or scopes with role_dict if role not 'admin'"""
|
||||
for attr in role_dict.keys():
|
||||
if attr == 'description' or attr == 'scopes':
|
||||
if role.name == 'admin':
|
||||
admin_role_spec = [
|
||||
r for r in get_default_roles() if r['name'] == 'admin'
|
||||
][0]
|
||||
if role_dict[attr] != admin_role_spec[attr]:
|
||||
raise ValueError(
|
||||
'admin role description or scopes cannot be overwritten'
|
||||
)
|
||||
else:
|
||||
if role_dict[attr] != getattr(role, attr):
|
||||
setattr(role, attr, role_dict[attr])
|
||||
app_log.info(
|
||||
'Role %r %r attribute has been changed', role.name, attr
|
||||
)
|
||||
|
||||
|
||||
_role_name_pattern = re.compile(r'^[a-z][a-z0-9\-_~\.]{1,253}[a-z0-9]$')
|
||||
|
||||
|
||||
def _validate_role_name(name):
|
||||
"""Ensure a role has a valid name
|
||||
|
||||
Raises ValueError if role name is invalid
|
||||
"""
|
||||
if not _role_name_pattern.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid role name: {name!r}."
|
||||
" Role names must:\n"
|
||||
" - be 3-255 characters\n"
|
||||
" - contain only lowercase ascii letters, numbers, and URL unreserved special characters '-.~_'\n"
|
||||
" - start with a letter\n"
|
||||
" - end with letter or number\n"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def create_role(db, role_dict):
|
||||
"""Adds a new role to database or modifies an existing one"""
|
||||
default_roles = get_default_roles()
|
||||
|
||||
if 'name' not in role_dict.keys():
|
||||
raise KeyError('Role definition must have a name')
|
||||
else:
|
||||
name = role_dict['name']
|
||||
_validate_role_name(name)
|
||||
role = orm.Role.find(db, name)
|
||||
|
||||
description = role_dict.get('description')
|
||||
scopes = role_dict.get('scopes')
|
||||
|
||||
# check if the provided scopes exist
|
||||
if scopes:
|
||||
_check_scopes(*scopes, rolename=role_dict['name'])
|
||||
|
||||
if role is None:
|
||||
if not scopes:
|
||||
app_log.warning('Warning: New defined role %s has no scopes', name)
|
||||
|
||||
role = orm.Role(name=name, description=description, scopes=scopes)
|
||||
db.add(role)
|
||||
if role_dict not in default_roles:
|
||||
app_log.info('Role %s added to database', name)
|
||||
else:
|
||||
_overwrite_role(role, role_dict)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_role(db, rolename):
|
||||
"""Removes a role from database"""
|
||||
# default roles are not removable
|
||||
default_roles = get_default_roles()
|
||||
if any(role['name'] == rolename for role in default_roles):
|
||||
raise ValueError('Default role %r cannot be removed', rolename)
|
||||
|
||||
role = orm.Role.find(db, rolename)
|
||||
if role:
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
app_log.info('Role %s has been deleted', rolename)
|
||||
else:
|
||||
raise NameError('Cannot remove role %r that does not exist', rolename)
|
||||
|
||||
|
||||
def existing_only(func):
|
||||
"""Decorator for checking if objects and roles exist"""
|
||||
|
||||
def _check_existence(db, entity, rolename):
|
||||
role = orm.Role.find(db, rolename)
|
||||
if entity is None:
|
||||
raise ValueError(
|
||||
"%r of kind %r does not exist" % (entity, type(entity).__name__)
|
||||
)
|
||||
elif role is None:
|
||||
raise ValueError("Role %r does not exist" % rolename)
|
||||
else:
|
||||
func(db, entity, role)
|
||||
|
||||
return _check_existence
|
||||
|
||||
|
||||
@existing_only
|
||||
def grant_role(db, entity, rolename):
|
||||
"""Adds a role for users, services, groups or tokens"""
|
||||
if isinstance(entity, orm.APIToken):
|
||||
entity_repr = entity
|
||||
else:
|
||||
entity_repr = entity.name
|
||||
|
||||
if rolename not in entity.roles:
|
||||
entity.roles.append(rolename)
|
||||
db.commit()
|
||||
app_log.info(
|
||||
'Adding role %s for %s: %s',
|
||||
rolename.name,
|
||||
type(entity).__name__,
|
||||
entity_repr,
|
||||
)
|
||||
|
||||
|
||||
@existing_only
|
||||
def strip_role(db, entity, rolename):
|
||||
"""Removes a role for users, services, groups or tokens"""
|
||||
if isinstance(entity, orm.APIToken):
|
||||
entity_repr = entity
|
||||
else:
|
||||
entity_repr = entity.name
|
||||
if rolename in entity.roles:
|
||||
entity.roles.remove(rolename)
|
||||
db.commit()
|
||||
app_log.info(
|
||||
'Removing role %s for %s: %s',
|
||||
rolename.name,
|
||||
type(entity).__name__,
|
||||
entity_repr,
|
||||
)
|
||||
|
||||
|
||||
def _switch_default_role(db, obj, admin):
|
||||
"""Switch between default user/service 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, current_role, new_role):
|
||||
if current_role in obj.roles:
|
||||
strip_role(db, entity=obj, rolename=current_role.name)
|
||||
# only add new default role if the user has no other roles
|
||||
if len(obj.roles) < 1:
|
||||
grant_role(db, entity=obj, rolename=new_role.name)
|
||||
|
||||
if admin:
|
||||
add_and_remove(db, obj, user_role, admin_role)
|
||||
else:
|
||||
add_and_remove(db, obj, admin_role, user_role)
|
||||
|
||||
|
||||
def _token_allowed_role(db, token, role):
|
||||
"""Checks if requested role for token does not grant the token
|
||||
higher permissions than the token's owner has
|
||||
|
||||
Returns:
|
||||
True if requested permissions are within the owner's permissions, False otherwise
|
||||
"""
|
||||
owner = token.user
|
||||
if owner is None:
|
||||
owner = token.service
|
||||
|
||||
if owner is None:
|
||||
raise ValueError(f"Owner not found for {token}")
|
||||
|
||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||
|
||||
implicit_permissions = {'all', 'read:all'}
|
||||
explicit_scopes = expanded_scopes - implicit_permissions
|
||||
# ignore horizontal filters
|
||||
no_filter_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope for scope in explicit_scopes
|
||||
}
|
||||
# find the owner's scopes
|
||||
expanded_owner_scopes = expand_roles_to_scopes(owner)
|
||||
# ignore horizontal filters
|
||||
no_filter_owner_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope
|
||||
for scope in expanded_owner_scopes
|
||||
}
|
||||
disallowed_scopes = no_filter_scopes.difference(no_filter_owner_scopes)
|
||||
if not disallowed_scopes:
|
||||
# no scopes requested outside owner's own scopes
|
||||
return True
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Token requesting scopes exceeding owner {owner.name}: {disallowed_scopes}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def assign_default_roles(db, entity):
|
||||
"""Assigns default role to an entity:
|
||||
users and services get 'user' role, or admin role if they have admin flag
|
||||
tokens get 'token' role
|
||||
"""
|
||||
if isinstance(entity, orm.Group):
|
||||
pass
|
||||
elif isinstance(entity, orm.APIToken):
|
||||
app_log.debug('Assigning default roles to tokens')
|
||||
default_token_role = orm.Role.find(db, 'token')
|
||||
if not entity.roles and (entity.user or entity.service) is not None:
|
||||
default_token_role.tokens.append(entity)
|
||||
app_log.info('Added role %s to token %s', default_token_role.name, entity)
|
||||
db.commit()
|
||||
# users and services can have 'user' or 'admin' roles as default
|
||||
else:
|
||||
kind = type(entity).__name__
|
||||
app_log.debug(f'Assigning default roles to {kind} {entity.name}')
|
||||
_switch_default_role(db, entity, entity.admin)
|
||||
|
||||
|
||||
def update_roles(db, entity, roles):
|
||||
"""Updates object's roles checking for requested permissions
|
||||
if object is orm.APIToken
|
||||
"""
|
||||
standard_permissions = {'all', 'read:all'}
|
||||
for rolename in roles:
|
||||
if isinstance(entity, orm.APIToken):
|
||||
role = orm.Role.find(db, rolename)
|
||||
if role:
|
||||
app_log.debug(
|
||||
'Checking token permissions against requested role %s', rolename
|
||||
)
|
||||
if _token_allowed_role(db, entity, role):
|
||||
role.tokens.append(entity)
|
||||
app_log.info('Adding role %s to token: %s', role.name, entity)
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Requested token role {rolename} of {entity} has more permissions than the token owner'
|
||||
)
|
||||
else:
|
||||
raise NameError('Role %r does not exist' % rolename)
|
||||
else:
|
||||
app_log.debug('Assigning default roles to %s', type(entity).__name__)
|
||||
grant_role(db, entity=entity, rolename=rolename)
|
||||
|
||||
|
||||
def check_for_default_roles(db, bearer):
|
||||
"""Checks that role bearers have at least one role (default if none).
|
||||
Groups can be without a role
|
||||
"""
|
||||
Class = orm.get_class(bearer)
|
||||
if Class in {orm.Group, orm.Service}:
|
||||
pass
|
||||
else:
|
||||
for obj in (
|
||||
db.query(Class)
|
||||
.outerjoin(orm.Role, Class.roles)
|
||||
.group_by(Class.id)
|
||||
.having(func.count(orm.Role.id) == 0)
|
||||
):
|
||||
assign_default_roles(db, obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def mock_roles(app, name, kind):
|
||||
"""Loads and assigns default roles for mocked objects"""
|
||||
Class = orm.get_class(kind)
|
||||
obj = Class.find(app.db, name=name)
|
||||
default_roles = get_default_roles()
|
||||
for role in default_roles:
|
||||
create_role(app.db, role)
|
||||
app_log.info('Assigning default roles to mocked %s: %s', kind[:-1], name)
|
||||
assign_default_roles(db=app.db, entity=obj)
|
609
jupyterhub/scopes.py
Normal file
609
jupyterhub/scopes.py
Normal file
@@ -0,0 +1,609 @@
|
||||
"""
|
||||
General scope definitions and utilities
|
||||
|
||||
Scope variable nomenclature
|
||||
---------------------------
|
||||
scopes: list of scopes with abbreviations (e.g., in role definition)
|
||||
expanded scopes: set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes)
|
||||
parsed scopes: dictionary JSON like format of expanded scopes
|
||||
intersection : set of expanded scopes as intersection of 2 expanded scope sets
|
||||
identify scopes: set of expanded scopes needed for identify (whoami) endpoints
|
||||
"""
|
||||
import functools
|
||||
import inspect
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
|
||||
import sqlalchemy as sa
|
||||
from tornado import web
|
||||
from tornado.log import app_log
|
||||
|
||||
from . import orm
|
||||
from . import roles
|
||||
|
||||
"""when modifying the scope definitions, make sure that `docs/source/rbac/generate-scope-table.py` is run
|
||||
so that changes are reflected in the documentation and REST API description."""
|
||||
scope_definitions = {
|
||||
'(no_scope)': {'description': 'Identify the owner of the requesting entity.'},
|
||||
'self': {
|
||||
'description': 'Your own resources',
|
||||
'doc_description': 'The user’s own resources _(metascope for users, resolves to (no_scope) for services)_',
|
||||
},
|
||||
'all': {
|
||||
'description': 'Anything you have access to',
|
||||
'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
|
||||
},
|
||||
'admin:users': {
|
||||
'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
|
||||
'subscopes': ['admin:auth_state', 'users', 'read:roles:users'],
|
||||
},
|
||||
'admin:auth_state': {'description': 'Read a user’s authentication state.'},
|
||||
'users': {
|
||||
'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
|
||||
'subscopes': ['read:users', 'users:activity'],
|
||||
},
|
||||
'read:users': {
|
||||
'description': 'Read user models (excluding including servers, tokens and authentication state).',
|
||||
'subscopes': [
|
||||
'read:users:name',
|
||||
'read:users:groups',
|
||||
'read:users:activity',
|
||||
],
|
||||
},
|
||||
'read:users:name': {'description': 'Read names of users.'},
|
||||
'read:users:groups': {'description': 'Read users’ group membership.'},
|
||||
'read:users:activity': {'description': 'Read time of last user activity.'},
|
||||
'read:roles': {
|
||||
'description': 'Read role assignments.',
|
||||
'subscopes': ['read:roles:users', 'read:roles:services', 'read:roles:groups'],
|
||||
},
|
||||
'read:roles:users': {'description': 'Read user role assignments.'},
|
||||
'read:roles:services': {'description': 'Read service role assignments.'},
|
||||
'read:roles:groups': {'description': 'Read group role assignments.'},
|
||||
'users:activity': {
|
||||
'description': 'Update time of last user activity.',
|
||||
'subscopes': ['read:users:activity'],
|
||||
},
|
||||
'admin:servers': {
|
||||
'description': 'Read, start, stop, create and delete user servers and their state.',
|
||||
'subscopes': ['admin:server_state', 'servers'],
|
||||
},
|
||||
'admin:server_state': {'description': 'Read and write users’ server state.'},
|
||||
'servers': {
|
||||
'description': 'Start and stop user servers.',
|
||||
'subscopes': ['read:servers'],
|
||||
},
|
||||
'read:servers': {
|
||||
'description': 'Read users’ names and their server models (excluding the server state).',
|
||||
'subscopes': ['read:users:name'],
|
||||
},
|
||||
'tokens': {
|
||||
'description': 'Read, write, create and delete user tokens.',
|
||||
'subscopes': ['read:tokens'],
|
||||
},
|
||||
'read:tokens': {'description': 'Read user tokens.'},
|
||||
'admin:groups': {
|
||||
'description': 'Read and write group information, create and delete groups.',
|
||||
'subscopes': ['groups', 'read:roles:groups'],
|
||||
},
|
||||
'groups': {
|
||||
'description': 'Read and write group information, including adding/removing users to/from groups.',
|
||||
'subscopes': ['read:groups'],
|
||||
},
|
||||
'read:groups': {
|
||||
'description': 'Read group models.',
|
||||
'subscopes': ['read:groups:name'],
|
||||
},
|
||||
'read:groups:name': {'description': 'Read group names.'},
|
||||
'read:services': {
|
||||
'description': 'Read service models.',
|
||||
'subscopes': ['read:services:name'],
|
||||
},
|
||||
'read:services:name': {'description': 'Read service names.'},
|
||||
'read:hub': {'description': 'Read detailed information about the Hub.'},
|
||||
'access:servers': {
|
||||
'description': 'Access user servers via API or browser.',
|
||||
},
|
||||
'access:services': {
|
||||
'description': 'Access services via API or browser.',
|
||||
},
|
||||
'proxy': {
|
||||
'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
|
||||
},
|
||||
'shutdown': {'description': 'Shutdown the hub.'},
|
||||
}
|
||||
|
||||
|
||||
class Scope(Enum):
|
||||
ALL = True
|
||||
|
||||
|
||||
def _intersect_expanded_scopes(scopes_a, scopes_b, db=None):
|
||||
"""Intersect two sets of scopes by comparing their permissions
|
||||
|
||||
Arguments:
|
||||
scopes_a, scopes_b: sets of expanded scopes
|
||||
db (optional): db connection for resolving group membership
|
||||
|
||||
Returns:
|
||||
intersection: set of expanded scopes as intersection of the arguments
|
||||
|
||||
If db is given, group membership will be accounted for in intersections,
|
||||
Otherwise, it can result in lower than intended permissions,
|
||||
(i.e. users!group=x & users!user=y will be empty, even if user y is in group x.)
|
||||
"""
|
||||
empty_set = frozenset()
|
||||
|
||||
# cached lookups for group membership of users and servers
|
||||
@lru_cache()
|
||||
def groups_for_user(username):
|
||||
"""Get set of group names for a given username"""
|
||||
user = db.query(orm.User).filter_by(name=username).first()
|
||||
if user is None:
|
||||
return empty_set
|
||||
else:
|
||||
return {group.name for group in user.groups}
|
||||
|
||||
@lru_cache()
|
||||
def groups_for_server(server):
|
||||
"""Get set of group names for a given server"""
|
||||
username, _, servername = server.partition("/")
|
||||
return groups_for_user(username)
|
||||
|
||||
parsed_scopes_a = parse_scopes(scopes_a)
|
||||
parsed_scopes_b = parse_scopes(scopes_b)
|
||||
|
||||
common_bases = parsed_scopes_a.keys() & parsed_scopes_b.keys()
|
||||
|
||||
common_filters = {}
|
||||
warned = False
|
||||
for base in common_bases:
|
||||
filters_a = parsed_scopes_a[base]
|
||||
filters_b = parsed_scopes_b[base]
|
||||
if filters_a == Scope.ALL:
|
||||
common_filters[base] = filters_b
|
||||
elif filters_b == Scope.ALL:
|
||||
common_filters[base] = filters_a
|
||||
else:
|
||||
common_entities = filters_a.keys() & filters_b.keys()
|
||||
all_entities = filters_a.keys() | filters_b.keys()
|
||||
|
||||
# if we don't have a db session, we can't check group membership
|
||||
# warn *if* there are non-overlapping user= and group= filters that we can't check
|
||||
if (
|
||||
db is None
|
||||
and not warned
|
||||
and 'group' in all_entities
|
||||
and ('user' in all_entities or 'server' in all_entities)
|
||||
):
|
||||
# this could resolve wrong if there's a user or server only on one side and a group only on the other
|
||||
# check both directions: A has group X not in B group list AND B has user Y not in A user list
|
||||
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
|
||||
for b_key in ('user', 'server'):
|
||||
if (
|
||||
not warned
|
||||
and "group" in a
|
||||
and b_key in b
|
||||
and a["group"].difference(b.get("group", []))
|
||||
and b[b_key].difference(a.get(b_key, []))
|
||||
):
|
||||
warnings.warn(
|
||||
f"{base}[!{b_key}={b[b_key]}, !group={a['group']}] combinations of filters present,"
|
||||
" without db access. Intersection between not considered."
|
||||
" May result in lower than intended permissions.",
|
||||
UserWarning,
|
||||
)
|
||||
warned = True
|
||||
|
||||
common_filters[base] = {
|
||||
entity: filters_a[entity] & filters_b[entity]
|
||||
for entity in common_entities
|
||||
}
|
||||
|
||||
# resolve hierarchies (group/user/server) in both directions
|
||||
common_servers = common_filters[base].get("server", set())
|
||||
common_users = common_filters[base].get("user", set())
|
||||
|
||||
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
|
||||
if 'server' in a and b.get('server') != a['server']:
|
||||
# skip already-added servers (includes overlapping servers)
|
||||
servers = a['server'].difference(common_servers)
|
||||
|
||||
# resolve user/server hierarchy
|
||||
if servers and 'user' in b:
|
||||
for server in servers:
|
||||
username, _, servername = server.partition("/")
|
||||
if username in b['user']:
|
||||
common_servers.add(server)
|
||||
|
||||
# resolve group/server hierarchy if db available
|
||||
servers = servers.difference(common_servers)
|
||||
if db is not None and servers and 'group' in b:
|
||||
for server in servers:
|
||||
server_groups = groups_for_server(server)
|
||||
if server_groups & b['group']:
|
||||
common_servers.add(server)
|
||||
|
||||
# resolve group/user hierarchy if db available and user sets aren't identical
|
||||
if (
|
||||
db is not None
|
||||
and 'user' in a
|
||||
and 'group' in b
|
||||
and b.get('user') != a['user']
|
||||
):
|
||||
# skip already-added users (includes overlapping users)
|
||||
users = a['user'].difference(common_users)
|
||||
for username in users:
|
||||
groups = groups_for_user(username)
|
||||
if groups & b["group"]:
|
||||
common_users.add(username)
|
||||
|
||||
# add server filter if there wasn't one before
|
||||
if common_servers and "server" not in common_filters[base]:
|
||||
common_filters[base]["server"] = common_servers
|
||||
|
||||
# add user filter if it's non-empty and there wasn't one before
|
||||
if common_users and "user" not in common_filters[base]:
|
||||
common_filters[base]["user"] = common_users
|
||||
|
||||
return unparse_scopes(common_filters)
|
||||
|
||||
|
||||
def get_scopes_for(orm_object):
|
||||
"""Find scopes for a given user or token from their roles and resolve permissions
|
||||
|
||||
Arguments:
|
||||
orm_object: orm object or User wrapper
|
||||
|
||||
Returns:
|
||||
expanded scopes (set) for the orm object
|
||||
or
|
||||
intersection (set) if orm_object == orm.APIToken
|
||||
"""
|
||||
expanded_scopes = set()
|
||||
if orm_object is None:
|
||||
return expanded_scopes
|
||||
|
||||
if not isinstance(orm_object, orm.Base):
|
||||
from .user import User
|
||||
|
||||
if isinstance(orm_object, User):
|
||||
orm_object = orm_object.orm_user
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Only allow orm objects or User wrappers, got {orm_object}"
|
||||
)
|
||||
|
||||
if isinstance(orm_object, orm.APIToken):
|
||||
app_log.warning(f"Authenticated with token {orm_object}")
|
||||
owner = orm_object.user or orm_object.service
|
||||
token_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
if orm_object.client_id != "jupyterhub":
|
||||
# oauth tokens can be used to access the service issuing the token,
|
||||
# assuming the owner itself still has permission to do so
|
||||
spawner = orm_object.oauth_client.spawner
|
||||
if spawner:
|
||||
token_scopes.add(
|
||||
f"access:servers!server={spawner.user.name}/{spawner.name}"
|
||||
)
|
||||
else:
|
||||
service = orm_object.oauth_client.service
|
||||
if service:
|
||||
token_scopes.add(f"access:services!service={service.name}")
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Token {orm_object} has no associated service or spawner!"
|
||||
)
|
||||
|
||||
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||
|
||||
if token_scopes == {'all'}:
|
||||
# token_scopes is only 'all', return owner scopes as-is
|
||||
# short-circuit common case where we don't need to compute an intersection
|
||||
return owner_scopes
|
||||
|
||||
if 'all' in token_scopes:
|
||||
token_scopes.remove('all')
|
||||
token_scopes |= owner_scopes
|
||||
|
||||
intersection = _intersect_expanded_scopes(
|
||||
token_scopes,
|
||||
owner_scopes,
|
||||
db=sa.inspect(orm_object).session,
|
||||
)
|
||||
discarded_token_scopes = token_scopes - intersection
|
||||
|
||||
# Not taking symmetric difference here because token owner can naturally have more scopes than token
|
||||
if discarded_token_scopes:
|
||||
app_log.warning(
|
||||
"discarding scopes [%s], not present in owner roles"
|
||||
% ", ".join(discarded_token_scopes)
|
||||
)
|
||||
expanded_scopes = intersection
|
||||
else:
|
||||
expanded_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||
"""
|
||||
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
|
||||
Assumptions:
|
||||
filter_ != Scope.ALL
|
||||
"""
|
||||
if not (filter_ == 'user' and 'group' in sub_scope):
|
||||
return False
|
||||
if 'user' in sub_scope:
|
||||
return filter_value not in sub_scope['user']
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
|
||||
"""Check if username is present in set of allowed groups"""
|
||||
user = handler.find_user(user_name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No access to resources or resources not found")
|
||||
group_names = {group.name for group in user.groups}
|
||||
return bool(set(scope_group_names) & group_names)
|
||||
|
||||
|
||||
def _check_scope_access(api_handler, req_scope, **kwargs):
|
||||
"""Check if scopes satisfy requirements
|
||||
Returns True for (potentially restricted) access, False for refused access
|
||||
"""
|
||||
# Parse user name and server name together
|
||||
try:
|
||||
api_name = api_handler.request.path
|
||||
except AttributeError:
|
||||
api_name = type(api_handler).__name__
|
||||
if 'user' in kwargs and 'server' in kwargs:
|
||||
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
|
||||
if req_scope not in api_handler.parsed_scopes:
|
||||
app_log.debug("No access to %s via %s", api_name, req_scope)
|
||||
return False
|
||||
if api_handler.parsed_scopes[req_scope] == Scope.ALL:
|
||||
app_log.debug("Unrestricted access to %s via %s", api_name, req_scope)
|
||||
return True
|
||||
# Apply filters
|
||||
sub_scope = api_handler.parsed_scopes[req_scope]
|
||||
if not kwargs:
|
||||
app_log.debug(
|
||||
"Client has restricted access to %s via %s. Internal filtering may apply",
|
||||
api_name,
|
||||
req_scope,
|
||||
)
|
||||
return True
|
||||
for (filter_, filter_value) in kwargs.items():
|
||||
if filter_ in sub_scope and filter_value in sub_scope[filter_]:
|
||||
app_log.debug("Argument-based access to %s via %s", api_name, req_scope)
|
||||
return True
|
||||
if _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||
group_names = sub_scope['group']
|
||||
if _check_user_in_expanded_scope(api_handler, filter_value, group_names):
|
||||
app_log.debug("Restricted client access supported with group expansion")
|
||||
return True
|
||||
app_log.debug(
|
||||
"Client access refused; filters do not match API endpoint %s request" % api_name
|
||||
)
|
||||
raise web.HTTPError(404, "No access to resources or resources not found")
|
||||
|
||||
|
||||
def parse_scopes(scope_list):
|
||||
"""
|
||||
Parses scopes and filters in something akin to JSON style
|
||||
|
||||
For instance, scope list ["users", "groups!group=foo", "servers!server=user/bar", "servers!server=user/baz"]
|
||||
would lead to scope model
|
||||
{
|
||||
"users":scope.ALL,
|
||||
"admin:users":{
|
||||
"user":[
|
||||
"alice"
|
||||
]
|
||||
},
|
||||
"servers":{
|
||||
"server":[
|
||||
"user/bar",
|
||||
"user/baz"
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
parsed_scopes = {}
|
||||
for scope in scope_list:
|
||||
base_scope, _, filter_ = scope.partition('!')
|
||||
if not filter_:
|
||||
parsed_scopes[base_scope] = Scope.ALL
|
||||
elif base_scope not in parsed_scopes:
|
||||
parsed_scopes[base_scope] = {}
|
||||
|
||||
if parsed_scopes[base_scope] != Scope.ALL:
|
||||
key, _, value = filter_.partition('=')
|
||||
if key not in parsed_scopes[base_scope]:
|
||||
parsed_scopes[base_scope][key] = set([value])
|
||||
else:
|
||||
parsed_scopes[base_scope][key].add(value)
|
||||
return parsed_scopes
|
||||
|
||||
|
||||
def unparse_scopes(parsed_scopes):
|
||||
"""Turn a parsed_scopes dictionary back into a expanded scopes set"""
|
||||
expanded_scopes = set()
|
||||
for base, filters in parsed_scopes.items():
|
||||
if filters == Scope.ALL:
|
||||
expanded_scopes.add(base)
|
||||
else:
|
||||
for entity, names_list in filters.items():
|
||||
for name in names_list:
|
||||
expanded_scopes.add(f'{base}!{entity}={name}')
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def needs_scope(*scopes):
|
||||
"""Decorator to restrict access to users or services with the required scope"""
|
||||
|
||||
for scope in scopes:
|
||||
if scope not in scope_definitions:
|
||||
raise ValueError(f"Scope {scope} is not a valid scope")
|
||||
|
||||
def scope_decorator(func):
|
||||
@functools.wraps(func)
|
||||
def _auth_func(self, *args, **kwargs):
|
||||
sig = inspect.signature(func)
|
||||
bound_sig = sig.bind(self, *args, **kwargs)
|
||||
bound_sig.apply_defaults()
|
||||
# Load scopes in case they haven't been loaded yet
|
||||
if not hasattr(self, 'expanded_scopes'):
|
||||
self.expanded_scopes = {}
|
||||
self.parsed_scopes = {}
|
||||
|
||||
s_kwargs = {}
|
||||
for resource in {'user', 'server', 'group', 'service'}:
|
||||
resource_name = resource + '_name'
|
||||
if resource_name in bound_sig.arguments:
|
||||
resource_value = bound_sig.arguments[resource_name]
|
||||
s_kwargs[resource] = resource_value
|
||||
for scope in scopes:
|
||||
app_log.debug("Checking access via scope %s", scope)
|
||||
has_access = _check_scope_access(self, scope, **s_kwargs)
|
||||
if has_access:
|
||||
return func(self, *args, **kwargs)
|
||||
try:
|
||||
end_point = self.request.path
|
||||
except AttributeError:
|
||||
end_point = self.__name__
|
||||
app_log.warning(
|
||||
"Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
|
||||
end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)
|
||||
)
|
||||
)
|
||||
raise web.HTTPError(
|
||||
403,
|
||||
"Action is not authorized with current scopes; requires any of [{}]".format(
|
||||
", ".join(scopes)
|
||||
),
|
||||
)
|
||||
|
||||
return _auth_func
|
||||
|
||||
return scope_decorator
|
||||
|
||||
|
||||
def identify_scopes(obj):
|
||||
"""Return 'identify' scopes for an orm object
|
||||
|
||||
Arguments:
|
||||
obj: orm.User or orm.Service
|
||||
|
||||
Returns:
|
||||
identify scopes (set): set of scopes needed for 'identify' endpoints
|
||||
"""
|
||||
if isinstance(obj, orm.User):
|
||||
return {f"read:users:{field}!user={obj.name}" for field in {"name", "groups"}}
|
||||
elif isinstance(obj, orm.Service):
|
||||
return {f"read:services:{field}!service={obj.name}" for field in {"name"}}
|
||||
else:
|
||||
raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}")
|
||||
|
||||
|
||||
def check_scope_filter(sub_scope, orm_resource, kind):
|
||||
"""Return whether a sub_scope filter applies to a given resource.
|
||||
|
||||
param sub_scope: parsed_scopes filter (i.e. dict or Scope.ALL)
|
||||
param orm_resource: User or Service or Group or Spawner
|
||||
param kind: 'user' or 'service' or 'group' or 'server'.
|
||||
|
||||
Returns True or False
|
||||
"""
|
||||
if sub_scope is Scope.ALL:
|
||||
return True
|
||||
elif kind in sub_scope and orm_resource.name in sub_scope[kind]:
|
||||
return True
|
||||
|
||||
if kind == 'server':
|
||||
server_format = f"{orm_resource.user.name}/{orm_resource.name}"
|
||||
if server_format in sub_scope.get(kind, []):
|
||||
return True
|
||||
# Fall back on checking if we have user access
|
||||
if 'user' in sub_scope and orm_resource.user.name in sub_scope['user']:
|
||||
return True
|
||||
# Fall back on checking if we have group access for this user
|
||||
orm_resource = orm_resource.user
|
||||
kind = 'user'
|
||||
|
||||
if kind == 'user' and 'group' in sub_scope:
|
||||
group_names = {group.name for group in orm_resource.groups}
|
||||
user_in_group = bool(group_names & set(sub_scope['group']))
|
||||
if user_in_group:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def describe_parsed_scopes(parsed_scopes, username=None):
|
||||
"""Return list of descriptions of parsed scopes
|
||||
|
||||
Highly detailed, often redundant descriptions
|
||||
"""
|
||||
descriptions = []
|
||||
for scope, filters in parsed_scopes.items():
|
||||
base_text = scope_definitions[scope]["description"]
|
||||
if filters == Scope.ALL:
|
||||
# no filter
|
||||
filter_text = ""
|
||||
else:
|
||||
filter_chunks = []
|
||||
for kind, names in filters.items():
|
||||
if kind == 'user' and names == {username}:
|
||||
filter_chunks.append("only you")
|
||||
else:
|
||||
kind_text = kind
|
||||
if kind == 'group':
|
||||
kind_text = "users in group"
|
||||
if len(names) == 1:
|
||||
filter_chunks.append(f"{kind}: {list(names)[0]}")
|
||||
else:
|
||||
filter_chunks.append(f"{kind}s: {', '.join(names)}")
|
||||
filter_text = "; or ".join(filter_chunks)
|
||||
descriptions.append(
|
||||
{
|
||||
"scope": scope,
|
||||
"description": scope_definitions[scope]["description"],
|
||||
"filter": filter_text,
|
||||
}
|
||||
)
|
||||
return descriptions
|
||||
|
||||
|
||||
def describe_raw_scopes(raw_scopes, username=None):
|
||||
"""Return list of descriptions of raw scopes
|
||||
|
||||
A much shorter list than describe_parsed_scopes
|
||||
"""
|
||||
descriptions = []
|
||||
for raw_scope in raw_scopes:
|
||||
scope, _, filter_ = raw_scope.partition("!")
|
||||
base_text = scope_definitions[scope]["description"]
|
||||
if not filter_:
|
||||
# no filter
|
||||
filter_text = ""
|
||||
elif filter_ == "user":
|
||||
filter_text = "only you"
|
||||
else:
|
||||
kind, _, name = filter_.partition("=")
|
||||
if kind == "user" and name == username:
|
||||
filter_text = "only you"
|
||||
else:
|
||||
kind_text = kind
|
||||
if kind == 'group':
|
||||
kind_text = "users in group"
|
||||
filter_text = f"{kind_text} {name}"
|
||||
descriptions.append(
|
||||
{
|
||||
"scope": scope,
|
||||
"description": scope_definitions[scope]["description"],
|
||||
"filter": filter_text,
|
||||
}
|
||||
)
|
||||
return descriptions
|
@@ -1,7 +1,7 @@
|
||||
"""Authenticating services with JupyterHub.
|
||||
|
||||
Cookies are sent to the Hub for verification. The Hub replies with a JSON
|
||||
model describing the authenticated user.
|
||||
Tokens are sent to the Hub for verification.
|
||||
The Hub replies with a JSON model describing the authenticated user.
|
||||
|
||||
``HubAuth`` can be used in any application, even outside tornado.
|
||||
|
||||
@@ -10,6 +10,7 @@ authenticate with the Hub.
|
||||
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -20,7 +21,6 @@ import time
|
||||
import uuid
|
||||
import warnings
|
||||
from unittest import mock
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
@@ -33,13 +33,50 @@ from traitlets import Dict
|
||||
from traitlets import Instance
|
||||
from traitlets import Integer
|
||||
from traitlets import observe
|
||||
from traitlets import Set
|
||||
from traitlets import Unicode
|
||||
from traitlets import validate
|
||||
from traitlets.config import SingletonConfigurable
|
||||
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
def check_scopes(required_scopes, scopes):
|
||||
"""Check that required_scope(s) are in scopes
|
||||
|
||||
Returns the subset of scopes matching required_scopes,
|
||||
which is truthy if any scopes match any required scopes.
|
||||
|
||||
Correctly resolves scope filters *except* for groups -> user,
|
||||
e.g. require: access:server!user=x, have: access:server!group=y
|
||||
will not grant access to user x even if user x is in group y.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
required_scopes: set
|
||||
The set of scopes required.
|
||||
scopes: set
|
||||
The set (or list) of scopes to check against required_scopes
|
||||
|
||||
Returns
|
||||
-------
|
||||
relevant_scopes: set
|
||||
The set of scopes in required_scopes that are present in scopes,
|
||||
which is truthy if any required scopes are present,
|
||||
and falsy otherwise.
|
||||
"""
|
||||
if isinstance(required_scopes, str):
|
||||
required_scopes = {required_scopes}
|
||||
|
||||
intersection = _intersect_expanded_scopes(required_scopes, scopes)
|
||||
# re-intersect with required_scopes in case the intersection
|
||||
# applies stricter filters than required_scopes declares
|
||||
# e.g. required_scopes = {'read:users'} and intersection has only {'read:users!user=x'}
|
||||
return set(required_scopes) & intersection
|
||||
|
||||
|
||||
class _ExpiringDict(dict):
|
||||
"""Dict-like cache for Hub API requests
|
||||
|
||||
@@ -113,9 +150,15 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
This can be used by any application.
|
||||
|
||||
Use this base class only for direct, token-authenticated applications
|
||||
(web APIs).
|
||||
For applications that support direct visits from browsers,
|
||||
use HubOAuth to enable OAuth redirect-based authentication.
|
||||
|
||||
|
||||
If using tornado, use via :class:`HubAuthenticated` mixin.
|
||||
If using manually, use the ``.user_for_cookie(cookie_value)`` method
|
||||
to identify the user corresponding to a given cookie value.
|
||||
If using manually, use the ``.user_for_token(token_value)`` method
|
||||
to identify the user owning a given token.
|
||||
|
||||
The following config must be set:
|
||||
|
||||
@@ -129,9 +172,6 @@ class HubAuth(SingletonConfigurable):
|
||||
- cookie_cache_max_age: the number of seconds responses
|
||||
from the Hub should be cached.
|
||||
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
||||
- cookie_name: the name of the cookie I should be using,
|
||||
if different from the default (unlikely).
|
||||
|
||||
"""
|
||||
|
||||
hub_host = Unicode(
|
||||
@@ -239,10 +279,6 @@ class HubAuth(SingletonConfigurable):
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
cookie_name = Unicode(
|
||||
'jupyterhub-services', help="""The name of the cookie I should be looking for"""
|
||||
).tag(config=True)
|
||||
|
||||
cookie_options = Dict(
|
||||
help="""Additional options to pass when setting cookies.
|
||||
|
||||
@@ -286,12 +322,30 @@ class HubAuth(SingletonConfigurable):
|
||||
def _default_cache(self):
|
||||
return _ExpiringDict(self.cache_max_age)
|
||||
|
||||
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
|
||||
oauth_scopes = Set(
|
||||
Unicode(),
|
||||
help="""OAuth scopes to use for allowing access.
|
||||
|
||||
Get from $JUPYTERHUB_OAUTH_SCOPES by default.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@default('oauth_scopes')
|
||||
def _default_scopes(self):
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
||||
if env_scopes:
|
||||
return set(json.loads(env_scopes))
|
||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||
if service_name:
|
||||
return {f'access:services!service={service_name}'}
|
||||
return set()
|
||||
|
||||
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||
"""Identify a user with the Hub
|
||||
|
||||
Args:
|
||||
url (str): The API URL to check the Hub for authorization
|
||||
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
|
||||
(e.g. http://127.0.0.1:8081/hub/api/user)
|
||||
cache_key (str): The key for checking the cache
|
||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||
|
||||
@@ -309,7 +363,12 @@ class HubAuth(SingletonConfigurable):
|
||||
except KeyError:
|
||||
app_log.debug("HubAuth cache miss: %s", cache_key)
|
||||
|
||||
data = self._api_request('GET', url, allow_404=True)
|
||||
data = self._api_request(
|
||||
'GET',
|
||||
url,
|
||||
headers={"Authorization": "token " + api_token},
|
||||
allow_403=True,
|
||||
)
|
||||
if data is None:
|
||||
app_log.warning("No Hub user identified for request")
|
||||
else:
|
||||
@@ -321,7 +380,7 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
def _api_request(self, method, url, **kwargs):
|
||||
"""Make an API request"""
|
||||
allow_404 = kwargs.pop('allow_404', False)
|
||||
allow_403 = kwargs.pop('allow_403', False)
|
||||
headers = kwargs.setdefault('headers', {})
|
||||
headers.setdefault('Authorization', 'token %s' % self.api_token)
|
||||
if "cert" not in kwargs and self.certfile and self.keyfile:
|
||||
@@ -345,7 +404,7 @@ class HubAuth(SingletonConfigurable):
|
||||
raise HTTPError(500, msg)
|
||||
|
||||
data = None
|
||||
if r.status_code == 404 and allow_404:
|
||||
if r.status_code == 403 and allow_403:
|
||||
pass
|
||||
elif r.status_code == 403:
|
||||
app_log.error(
|
||||
@@ -389,26 +448,9 @@ class HubAuth(SingletonConfigurable):
|
||||
return data
|
||||
|
||||
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
|
||||
"""Ask the Hub to identify the user for a given cookie.
|
||||
|
||||
Args:
|
||||
encrypted_cookie (str): the cookie value (not decrypted, the Hub will do that)
|
||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||
|
||||
Returns:
|
||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
||||
|
||||
The 'name' field contains the user's name.
|
||||
"""
|
||||
return self._check_hub_authorization(
|
||||
url=url_path_join(
|
||||
self.api_url,
|
||||
"authorizations/cookie",
|
||||
self.cookie_name,
|
||||
quote(encrypted_cookie, safe=''),
|
||||
),
|
||||
cache_key='cookie:{}:{}'.format(session_id, encrypted_cookie),
|
||||
use_cache=use_cache,
|
||||
"""Deprecated and removed. Use HubOAuth to authenticate browsers."""
|
||||
raise RuntimeError(
|
||||
"Identifying users by shared cookie is removed in JupyterHub 2.0. Use OAuth tokens."
|
||||
)
|
||||
|
||||
def user_for_token(self, token, use_cache=True, session_id=''):
|
||||
@@ -425,14 +467,19 @@ class HubAuth(SingletonConfigurable):
|
||||
"""
|
||||
return self._check_hub_authorization(
|
||||
url=url_path_join(
|
||||
self.api_url, "authorizations/token", quote(token, safe='')
|
||||
self.api_url,
|
||||
"user",
|
||||
),
|
||||
api_token=token,
|
||||
cache_key='token:{}:{}'.format(
|
||||
session_id,
|
||||
hashlib.sha256(token.encode("utf8", "replace")).hexdigest(),
|
||||
),
|
||||
cache_key='token:{}:{}'.format(session_id, token),
|
||||
use_cache=use_cache,
|
||||
)
|
||||
|
||||
auth_header_name = 'Authorization'
|
||||
auth_header_pat = re.compile(r'token\s+(.+)', re.IGNORECASE)
|
||||
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||
|
||||
def get_token(self, handler):
|
||||
"""Get the user token from a request
|
||||
@@ -453,10 +500,8 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
def _get_user_cookie(self, handler):
|
||||
"""Get the user model from a cookie"""
|
||||
encrypted_cookie = handler.get_cookie(self.cookie_name)
|
||||
session_id = self.get_session_id(handler)
|
||||
if encrypted_cookie:
|
||||
return self.user_for_cookie(encrypted_cookie, session_id=session_id)
|
||||
# overridden in HubOAuth to store the access token after oauth
|
||||
return None
|
||||
|
||||
def get_session_id(self, handler):
|
||||
"""Get the jupyterhub session id
|
||||
@@ -505,10 +550,17 @@ class HubAuth(SingletonConfigurable):
|
||||
app_log.debug("No user identified")
|
||||
return user_model
|
||||
|
||||
def check_scopes(self, required_scopes, user):
|
||||
"""Check whether the user has required scope(s)"""
|
||||
return check_scopes(required_scopes, set(user["scopes"]))
|
||||
|
||||
|
||||
class HubOAuth(HubAuth):
|
||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
||||
|
||||
Use this class if you want users to be able to visit your service with a browser.
|
||||
They will be authenticated via OAuth with the Hub.
|
||||
|
||||
.. versionadded: 0.8
|
||||
"""
|
||||
|
||||
@@ -772,12 +824,26 @@ class UserNotAllowed(Exception):
|
||||
)
|
||||
|
||||
|
||||
class HubAuthenticated(object):
|
||||
class HubAuthenticated:
|
||||
"""Mixin for tornado handlers that are authenticated with JupyterHub
|
||||
|
||||
A handler that mixes this in must have the following attributes/properties:
|
||||
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||
Default comes from .hub_auth.oauth_scopes,
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
||||
Default values include:
|
||||
- 'access:services', 'access:services!service={service_name}' for services
|
||||
- 'access:servers', 'access:servers!user={user}',
|
||||
'access:servers!server={user}/{server_name}'
|
||||
for single-user servers
|
||||
|
||||
If hub_scopes is not used (e.g. JupyterHub 1.x),
|
||||
these additional properties can be used:
|
||||
|
||||
- .allow_admin: If True, allow any admin user.
|
||||
Default: False.
|
||||
- .hub_users: A set of usernames to allow.
|
||||
If left unspecified or None, username will not be checked.
|
||||
- .hub_groups: A set of group names to allow.
|
||||
@@ -804,13 +870,19 @@ class HubAuthenticated(object):
|
||||
hub_groups = None # set of allowed groups
|
||||
allow_admin = False # allow any admin user access
|
||||
|
||||
@property
|
||||
def hub_scopes(self):
|
||||
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
|
||||
return self.hub_auth.oauth_scopes or None
|
||||
|
||||
@property
|
||||
def allow_all(self):
|
||||
"""Property indicating that all successfully identified user
|
||||
or service should be allowed.
|
||||
"""
|
||||
return (
|
||||
self.hub_services is None
|
||||
self.hub_scopes is None
|
||||
and self.hub_services is None
|
||||
and self.hub_users is None
|
||||
and self.hub_groups is None
|
||||
and not self.allow_admin
|
||||
@@ -852,22 +924,43 @@ class HubAuthenticated(object):
|
||||
|
||||
Returns the input if the user should be allowed, None otherwise.
|
||||
|
||||
Override if you want to check anything other than the username's presence in hub_users list.
|
||||
Override for custom logic in authenticating users.
|
||||
|
||||
Args:
|
||||
model (dict): the user or service model returned from :class:`HubAuth`
|
||||
user_model (dict): the user or service model returned from :class:`HubAuth`
|
||||
Returns:
|
||||
user_model (dict): The user model if the user should be allowed, None otherwise.
|
||||
"""
|
||||
|
||||
name = model['name']
|
||||
kind = model.setdefault('kind', 'user')
|
||||
|
||||
if self.allow_all:
|
||||
app_log.debug(
|
||||
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
|
||||
)
|
||||
return model
|
||||
|
||||
if self.hub_scopes:
|
||||
scopes = self.hub_auth.check_scopes(self.hub_scopes, model)
|
||||
if scopes:
|
||||
app_log.debug(
|
||||
f"Allowing Hub {kind} {name} based on oauth scopes {scopes}"
|
||||
)
|
||||
return model
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Not allowing Hub {kind} {name}: missing required scopes"
|
||||
)
|
||||
app_log.debug(
|
||||
f"Hub {kind} {name} needs scope(s) {self.hub_scopes}, has scope(s) {model['scopes']}"
|
||||
)
|
||||
# if hub_scopes are used, *only* hub_scopes are used
|
||||
# note: this means successful authentication, but insufficient permission
|
||||
raise UserNotAllowed(model)
|
||||
|
||||
# proceed with the pre-2.0 way if hub_scopes is not set
|
||||
|
||||
if self.allow_admin and model.get('admin', False):
|
||||
app_log.debug("Allowing Hub admin %s", name)
|
||||
return model
|
||||
|
@@ -38,6 +38,7 @@ A hub-managed service with no URL::
|
||||
}
|
||||
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
import pipes
|
||||
@@ -50,7 +51,9 @@ from traitlets import default
|
||||
from traitlets import Dict
|
||||
from traitlets import HasTraits
|
||||
from traitlets import Instance
|
||||
from traitlets import List
|
||||
from traitlets import Unicode
|
||||
from traitlets import validate
|
||||
from traitlets.config import LoggingConfigurable
|
||||
|
||||
from .. import orm
|
||||
@@ -96,6 +99,14 @@ class _ServiceSpawner(LocalProcessSpawner):
|
||||
|
||||
cwd = Unicode()
|
||||
cmd = Command(minlen=0)
|
||||
_service_name = Unicode()
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
return [
|
||||
"access:services",
|
||||
f"access:services!service={self._service_name}",
|
||||
]
|
||||
|
||||
def make_preexec_fn(self, name):
|
||||
if not name:
|
||||
@@ -188,6 +199,19 @@ class Service(LoggingConfigurable):
|
||||
"""
|
||||
).tag(input=True)
|
||||
|
||||
oauth_roles = List(
|
||||
help="""OAuth allowed roles.
|
||||
|
||||
This sets the maximum and default roles
|
||||
assigned to oauth tokens issued for this service
|
||||
(i.e. tokens stored in browsers after authenticating with the server),
|
||||
defining what actions the service can take on behalf of logged-in users.
|
||||
|
||||
Default is an empty list, meaning minimal permissions to identify users,
|
||||
no actions can be taken on their behalf.
|
||||
"""
|
||||
).tag(input=True)
|
||||
|
||||
api_token = Unicode(
|
||||
help="""The API token to use for the service.
|
||||
|
||||
@@ -267,6 +291,7 @@ class Service(LoggingConfigurable):
|
||||
base_url = Unicode()
|
||||
db = Any()
|
||||
orm = Any()
|
||||
roles = Any()
|
||||
cookie_options = Dict()
|
||||
|
||||
oauth_provider = Any()
|
||||
@@ -283,6 +308,15 @@ class Service(LoggingConfigurable):
|
||||
def _default_client_id(self):
|
||||
return 'service-%s' % self.name
|
||||
|
||||
@validate("oauth_client_id")
|
||||
def _validate_client_id(self, proposal):
|
||||
if not proposal.value.startswith("service-"):
|
||||
raise ValueError(
|
||||
f"service {self.name} has oauth_client_id='{proposal.value}'."
|
||||
" Service oauth client ids must start with 'service-'"
|
||||
)
|
||||
return proposal.value
|
||||
|
||||
oauth_redirect_uri = Unicode(
|
||||
help="""OAuth redirect URI for this service.
|
||||
|
||||
@@ -305,6 +339,10 @@ class Service(LoggingConfigurable):
|
||||
"""
|
||||
return bool(self.server is not None or self.oauth_redirect_uri)
|
||||
|
||||
@property
|
||||
def oauth_client(self):
|
||||
return self.orm.oauth_client
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
if self.orm.server:
|
||||
@@ -332,7 +370,7 @@ class Service(LoggingConfigurable):
|
||||
managed=' managed' if self.managed else '',
|
||||
)
|
||||
|
||||
def start(self):
|
||||
async def start(self):
|
||||
"""Start a managed service"""
|
||||
if not self.managed:
|
||||
raise RuntimeError("Cannot start unmanaged service %s" % self)
|
||||
@@ -359,6 +397,7 @@ class Service(LoggingConfigurable):
|
||||
environment=env,
|
||||
api_token=self.api_token,
|
||||
oauth_client_id=self.oauth_client_id,
|
||||
_service_name=self.name,
|
||||
cookie_options=self.cookie_options,
|
||||
cwd=self.cwd,
|
||||
hub=self.hub,
|
||||
@@ -369,6 +408,8 @@ class Service(LoggingConfigurable):
|
||||
internal_certs_location=self.app.internal_certs_location,
|
||||
internal_trust_bundles=self.app.internal_trust_bundles,
|
||||
)
|
||||
if self.spawner.internal_ssl:
|
||||
self.spawner.cert_paths = await self.spawner.create_certs()
|
||||
self.spawner.start()
|
||||
self.proc = self.spawner.proc
|
||||
self.spawner.add_poll_callback(self._proc_stopped)
|
||||
@@ -379,7 +420,8 @@ class Service(LoggingConfigurable):
|
||||
self.log.error(
|
||||
"Service %s exited with status %i", self.name, self.proc.returncode
|
||||
)
|
||||
self.start()
|
||||
# schedule start
|
||||
asyncio.ensure_future(self.start())
|
||||
|
||||
async def stop(self):
|
||||
"""Stop a managed service"""
|
||||
|
@@ -161,7 +161,6 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
|
||||
aliases = {
|
||||
'user': 'SingleUserNotebookApp.user',
|
||||
'group': 'SingleUserNotebookApp.group',
|
||||
'cookie-name': 'HubAuth.cookie_name',
|
||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||
|
@@ -216,8 +216,32 @@ class Spawner(LoggingConfigurable):
|
||||
admin_access = Bool(False)
|
||||
api_token = Unicode()
|
||||
oauth_client_id = Unicode()
|
||||
|
||||
oauth_scopes = List(Unicode())
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
return [
|
||||
f"access:servers!server={self.user.name}/{self.name}",
|
||||
f"access:servers!user={self.user.name}",
|
||||
]
|
||||
|
||||
handler = Any()
|
||||
|
||||
oauth_roles = Union(
|
||||
[Callable(), List()],
|
||||
help="""Allowed roles for oauth tokens.
|
||||
|
||||
This sets the maximum and default roles
|
||||
assigned to oauth tokens issued by a single-user server's
|
||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||
defining what actions the server can take on behalf of logged-in users.
|
||||
|
||||
Default is an empty list, meaning minimal permissions to identify users,
|
||||
no actions can be taken on their behalf.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
will_resume = Bool(
|
||||
False,
|
||||
help="""Whether the Spawner will resume on next start
|
||||
@@ -789,6 +813,8 @@ class Spawner(LoggingConfigurable):
|
||||
self.user.url, self.name, 'oauth_callback'
|
||||
)
|
||||
|
||||
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
|
||||
|
||||
# Info previously passed on args
|
||||
env['JUPYTERHUB_USER'] = self.user.name
|
||||
env['JUPYTERHUB_SERVER_NAME'] = self.name
|
||||
|
@@ -27,9 +27,9 @@ Fixtures to add functionality or spawning behavior
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from getpass import getuser
|
||||
from subprocess import TimeoutExpired
|
||||
from unittest import mock
|
||||
@@ -44,11 +44,14 @@ import jupyterhub.services.service
|
||||
from . import mocking
|
||||
from .. import crypto
|
||||
from .. import orm
|
||||
from ..roles import create_role
|
||||
from ..roles import get_default_roles
|
||||
from ..roles import mock_roles
|
||||
from ..roles import update_roles
|
||||
from ..utils import random_port
|
||||
from .mocking import MockHub
|
||||
from .test_services import mockservice_cmd
|
||||
from .utils import add_user
|
||||
from .utils import ssl_setup
|
||||
|
||||
# global db session object
|
||||
_db = None
|
||||
@@ -123,7 +126,13 @@ def db():
|
||||
"""Get a db session"""
|
||||
global _db
|
||||
if _db is None:
|
||||
_db = orm.new_session_factory('sqlite:///:memory:')()
|
||||
# make sure some initial db contents are filled out
|
||||
# specifically, the 'default' jupyterhub oauth client
|
||||
app = MockHub(db_url='sqlite:///:memory:')
|
||||
app.init_db()
|
||||
_db = app.db
|
||||
for role in get_default_roles():
|
||||
create_role(_db, role)
|
||||
user = orm.User(name=getuser())
|
||||
_db.add(user)
|
||||
_db.commit()
|
||||
@@ -162,9 +171,14 @@ def cleanup_after(request, io_loop):
|
||||
allows cleanup of servers between tests
|
||||
without having to launch a whole new app
|
||||
"""
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _db is not None:
|
||||
# cleanup after failed transactions
|
||||
_db.rollback()
|
||||
|
||||
if not MockHub.initialized():
|
||||
return
|
||||
app = MockHub.instance()
|
||||
@@ -245,13 +259,16 @@ def _mockservice(request, app, url=False):
|
||||
):
|
||||
app.services = [spec]
|
||||
app.init_services()
|
||||
mock_roles(app, name, 'services')
|
||||
assert name in app._service_map
|
||||
service = app._service_map[name]
|
||||
token = service.orm.api_tokens[0]
|
||||
update_roles(app.db, token, roles=['token'])
|
||||
|
||||
async def start():
|
||||
# wait for proxy to be updated before starting the service
|
||||
await app.proxy.add_all_services(app._service_map)
|
||||
service.start()
|
||||
await service.start()
|
||||
|
||||
io_loop.run_sync(start)
|
||||
|
||||
@@ -265,7 +282,7 @@ def _mockservice(request, app, url=False):
|
||||
with raises(TimeoutExpired):
|
||||
service.proc.wait(1)
|
||||
if url:
|
||||
io_loop.run_sync(service.server.wait_up)
|
||||
io_loop.run_sync(partial(service.server.wait_up, http=True))
|
||||
return service
|
||||
|
||||
|
||||
@@ -325,3 +342,79 @@ def slow_bad_spawn(app):
|
||||
app.tornado_settings, {'spawner_class': mocking.SlowBadSpawner}
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@fixture
|
||||
def create_temp_role(app):
|
||||
"""Generate a temporary role with certain scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_roles = []
|
||||
index = [1]
|
||||
|
||||
def temp_role_creator(scopes, role_name=None):
|
||||
if not role_name:
|
||||
role_name = f'temp_role_{index[0]}'
|
||||
index[0] += 1
|
||||
temp_role = orm.Role(name=role_name, scopes=list(scopes))
|
||||
temp_roles.append(temp_role)
|
||||
app.db.add(temp_role)
|
||||
app.db.commit()
|
||||
return temp_role
|
||||
|
||||
yield temp_role_creator
|
||||
for role in temp_roles:
|
||||
app.db.delete(role)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
@fixture
|
||||
def create_user_with_scopes(app, create_temp_role):
|
||||
"""Generate a temporary user with specific scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_users = []
|
||||
counter = 0
|
||||
get_role = create_temp_role
|
||||
|
||||
def temp_user_creator(*scopes, name=None):
|
||||
nonlocal counter
|
||||
if name is None:
|
||||
counter += 1
|
||||
name = f"temp_user_{counter}"
|
||||
role = get_role(scopes)
|
||||
orm_user = orm.User(name=name)
|
||||
app.db.add(orm_user)
|
||||
app.db.commit()
|
||||
temp_users.append(orm_user)
|
||||
update_roles(app.db, orm_user, roles=[role.name])
|
||||
return app.users[orm_user.id]
|
||||
|
||||
yield temp_user_creator
|
||||
for user in temp_users:
|
||||
app.users.delete(user)
|
||||
|
||||
|
||||
@fixture
|
||||
def create_service_with_scopes(app, create_temp_role):
|
||||
"""Generate a temporary service with specific scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_service = []
|
||||
counter = 0
|
||||
role_function = create_temp_role
|
||||
|
||||
def temp_service_creator(*scopes, name=None):
|
||||
nonlocal counter
|
||||
if name is None:
|
||||
counter += 1
|
||||
name = f"temp_service_{counter}"
|
||||
role = role_function(scopes)
|
||||
app.services.append({'name': name})
|
||||
app.init_services()
|
||||
orm_service = orm.Service.find(app.db, name)
|
||||
app.db.commit()
|
||||
update_roles(app.db, orm_service, roles=[role.name])
|
||||
return orm_service
|
||||
|
||||
yield temp_service_creator
|
||||
for service in temp_service:
|
||||
app.db.delete(service)
|
||||
app.db.commit()
|
||||
|
@@ -43,6 +43,7 @@ from traitlets import Dict
|
||||
|
||||
from .. import metrics
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..app import JupyterHub
|
||||
from ..auth import PAMAuthenticator
|
||||
from ..objects import Server
|
||||
@@ -305,13 +306,15 @@ 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):
|
||||
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()
|
||||
|
||||
async def initialize(self, argv=None):
|
||||
@@ -329,6 +332,8 @@ class MockHub(JupyterHub):
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
metrics.TOTAL_USERS.inc()
|
||||
roles.assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
|
||||
def stop(self):
|
||||
super().stop()
|
||||
@@ -383,6 +388,10 @@ class MockSingleUserServer(SingleUserNotebookApp):
|
||||
def init_signal(self):
|
||||
pass
|
||||
|
||||
@default("log_level")
|
||||
def _default_log_level(self):
|
||||
return 10
|
||||
|
||||
|
||||
class StubSingleUserSpawner(MockSpawner):
|
||||
"""Spawner that starts a MockSingleUserServer in a thread."""
|
||||
@@ -420,6 +429,7 @@ class StubSingleUserSpawner(MockSpawner):
|
||||
app.initialize(args)
|
||||
assert app.hub_auth.oauth_client_id
|
||||
assert app.hub_auth.api_token
|
||||
assert app.hub_auth.oauth_scopes
|
||||
app.start()
|
||||
|
||||
self._thread = threading.Thread(target=_run)
|
||||
|
@@ -21,6 +21,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
from tornado import httpserver
|
||||
from tornado import ioloop
|
||||
from tornado import log
|
||||
from tornado import web
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
@@ -114,7 +115,9 @@ def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from tornado.options import parse_command_line
|
||||
from tornado.options import parse_command_line, options
|
||||
|
||||
parse_command_line()
|
||||
options.logging = 'debug'
|
||||
log.enable_pretty_logging()
|
||||
main()
|
||||
|
@@ -6,6 +6,7 @@ used in test_db.py
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
|
||||
import jupyterhub
|
||||
from jupyterhub import orm
|
||||
@@ -62,24 +63,27 @@ def populate_db(url):
|
||||
db.commit()
|
||||
|
||||
# create some oauth objects
|
||||
if jupyterhub.version_info >= (0, 8):
|
||||
# create oauth client
|
||||
client = orm.OAuthClient(identifier='oauth-client')
|
||||
db.add(client)
|
||||
db.commit()
|
||||
code = orm.OAuthCode(client_id=client.identifier)
|
||||
db.add(code)
|
||||
db.commit()
|
||||
access_token = orm.OAuthAccessToken(
|
||||
if jupyterhub.version_info < (2, 0):
|
||||
Token = partial(
|
||||
orm.OAuthAccessToken,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
)
|
||||
else:
|
||||
Token = orm.APIToken
|
||||
access_token = Token(
|
||||
client_id=client.identifier,
|
||||
user_id=user.id,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
)
|
||||
db.add(access_token)
|
||||
db.commit()
|
||||
|
||||
# set some timestamps added in 0.9
|
||||
if jupyterhub.version_info >= (0, 9):
|
||||
assert user.created
|
||||
assert admin.created
|
||||
# set last_activity
|
||||
|
@@ -25,6 +25,7 @@ from .utils import async_requests
|
||||
from .utils import auth_header
|
||||
from .utils import find_user
|
||||
|
||||
|
||||
# --------------------
|
||||
# Authentication tests
|
||||
# --------------------
|
||||
@@ -63,6 +64,7 @@ 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)
|
||||
@@ -149,13 +151,14 @@ 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)
|
||||
model.setdefault('pending', None)
|
||||
model.setdefault('created', TIMESTAMP)
|
||||
model.setdefault('last_activity', TIMESTAMP)
|
||||
model.setdefault('servers', {})
|
||||
# model.setdefault('servers', {})
|
||||
return model
|
||||
|
||||
|
||||
@@ -163,20 +166,31 @@ 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')
|
||||
r = await api_request(app, 'users', headers=auth_header(db, 'admin'))
|
||||
assert r.status_code == 200
|
||||
|
||||
users = sorted(r.json(), key=lambda d: d['name'])
|
||||
users = [normalize_user(u) for u in users]
|
||||
user_model = {
|
||||
'name': 'user',
|
||||
'admin': False,
|
||||
'roles': ['user'],
|
||||
'last_activity': None,
|
||||
'auth_state': None,
|
||||
}
|
||||
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'], 'auth_state': None}
|
||||
),
|
||||
fill_user(user_model),
|
||||
]
|
||||
|
||||
r = await api_request(app, 'users', headers=auth_header(db, 'user'))
|
||||
assert r.status_code == 403
|
||||
assert r.status_code == 200
|
||||
r_user_model = r.json()[0]
|
||||
assert r_user_model['name'] == user_model['name']
|
||||
|
||||
# Tests offset for pagination
|
||||
r = await api_request(app, 'users?offset=1')
|
||||
@@ -184,7 +198,11 @@ 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': 'user', 'admin': False})]
|
||||
assert users == [
|
||||
fill_user(
|
||||
{'name': 'user', 'admin': False, 'auth_state': None, 'roles': ['user']}
|
||||
)
|
||||
]
|
||||
|
||||
r = await api_request(app, 'users?offset=20')
|
||||
assert r.status_code == 200
|
||||
@@ -196,7 +214,11 @@ 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})]
|
||||
assert users == [
|
||||
fill_user(
|
||||
{'name': 'admin', 'admin': True, 'auth_state': None, 'roles': ['admin']}
|
||||
)
|
||||
]
|
||||
|
||||
r = await api_request(app, 'users?limit=0')
|
||||
assert r.status_code == 200
|
||||
@@ -283,21 +305,28 @@ async def test_get_self(app):
|
||||
oauth_client = orm.OAuthClient(identifier='eurydice')
|
||||
db.add(oauth_client)
|
||||
db.commit()
|
||||
oauth_token = orm.OAuthAccessToken(
|
||||
oauth_token = orm.APIToken(
|
||||
user=u.orm_user,
|
||||
client=oauth_client,
|
||||
oauth_client=oauth_client,
|
||||
token=token,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
)
|
||||
db.add(oauth_token)
|
||||
db.commit()
|
||||
r = await api_request(app, 'user', headers={'Authorization': 'token ' + token})
|
||||
r = await api_request(
|
||||
app,
|
||||
'user',
|
||||
headers={'Authorization': 'token ' + token},
|
||||
)
|
||||
r.raise_for_status()
|
||||
model = r.json()
|
||||
assert model['name'] == u.name
|
||||
|
||||
# invalid auth gets 403
|
||||
r = await api_request(app, 'user', headers={'Authorization': 'token notvalid'})
|
||||
r = await api_request(
|
||||
app,
|
||||
'user',
|
||||
headers={'Authorization': 'token notvalid'},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@@ -313,6 +342,7 @@ async def test_get_self_service(app, mockservice):
|
||||
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_add_user(app):
|
||||
db = app.db
|
||||
name = 'newuser'
|
||||
@@ -322,16 +352,25 @@ 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 orm.Role.find(db, 'user') in user.roles
|
||||
assert orm.Role.find(db, 'admin') not in user.roles
|
||||
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_get_user(app):
|
||||
name = 'user'
|
||||
r = await api_request(app, 'users', name)
|
||||
_ = await api_request(app, 'users', name, headers=auth_header(app.db, name))
|
||||
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
|
||||
@@ -359,6 +398,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']
|
||||
@@ -375,6 +415,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 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(
|
||||
@@ -395,6 +438,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']
|
||||
@@ -414,6 +458,8 @@ async def test_add_multi_user_admin(app):
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
assert orm.Role.find(db, 'user') not in user.roles
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@mark.user
|
||||
@@ -439,6 +485,7 @@ async def test_add_user_duplicate(app):
|
||||
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_add_admin(app):
|
||||
db = app.db
|
||||
name = 'newadmin'
|
||||
@@ -450,6 +497,9 @@ async def test_add_admin(app):
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
# assert newadmin has default 'admin' role
|
||||
assert orm.Role.find(db, 'user') not in user.roles
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@mark.user
|
||||
@@ -461,6 +511,7 @@ async def test_delete_user(app):
|
||||
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_make_admin(app):
|
||||
db = app.db
|
||||
name = 'admin2'
|
||||
@@ -470,15 +521,20 @@ async def test_make_admin(app):
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert not user.admin
|
||||
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})
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
assert orm.Role.find(db, 'user') not in user.roles
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@mark.user
|
||||
@@ -509,7 +565,6 @@ async def test_user_set_auth_state(app, auth_state_enabled):
|
||||
assert user.name == name
|
||||
user_auth_state = await user.get_auth_state()
|
||||
assert user_auth_state is None
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
@@ -518,7 +573,6 @@ async def test_user_set_auth_state(app, auth_state_enabled):
|
||||
data=json.dumps({'auth_state': auth_state}),
|
||||
headers=auth_header(app.db, name),
|
||||
)
|
||||
|
||||
assert r.status_code == 403
|
||||
user_auth_state = await user.get_auth_state()
|
||||
assert user_auth_state is None
|
||||
@@ -1161,76 +1215,13 @@ async def test_check_token(app):
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@mark.parametrize("headers, status", [({}, 200), ({'Authorization': 'token bad'}, 403)])
|
||||
@mark.parametrize("headers, status", [({}, 404), ({'Authorization': 'token bad'}, 404)])
|
||||
async def test_get_new_token_deprecated(app, headers, status):
|
||||
# request a new token
|
||||
r = await api_request(
|
||||
app, 'authorizations', 'token', method='post', headers=headers
|
||||
)
|
||||
assert r.status_code == status
|
||||
if status != 200:
|
||||
return
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == 'admin'
|
||||
|
||||
|
||||
async def test_token_formdata_deprecated(app):
|
||||
"""Create a token for a user with formdata and no auth header"""
|
||||
data = {'username': 'fake', 'password': 'fake'}
|
||||
r = await api_request(
|
||||
app,
|
||||
'authorizations',
|
||||
'token',
|
||||
method='post',
|
||||
data=json.dumps(data) if data else None,
|
||||
noauth=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == data['username']
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"as_user, for_user, status",
|
||||
[
|
||||
('admin', 'other', 200),
|
||||
('admin', 'missing', 400),
|
||||
('user', 'other', 403),
|
||||
('user', 'user', 200),
|
||||
],
|
||||
)
|
||||
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)
|
||||
data = {'username': for_user}
|
||||
headers = {'Authorization': 'token %s' % u.new_api_token()}
|
||||
r = await api_request(
|
||||
app,
|
||||
'authorizations',
|
||||
'token',
|
||||
method='post',
|
||||
data=json.dumps(data),
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == status
|
||||
reply = r.json()
|
||||
if status != 200:
|
||||
return
|
||||
assert 'token' in reply
|
||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == data['username']
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
@@ -1295,7 +1286,7 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
||||
"as_user, for_user, status",
|
||||
[
|
||||
('admin', 'other', 200),
|
||||
('admin', 'missing', 404),
|
||||
('admin', 'missing', 403),
|
||||
('user', 'other', 403),
|
||||
('user', 'user', 200),
|
||||
],
|
||||
@@ -1304,7 +1295,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(
|
||||
@@ -1321,6 +1312,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()
|
||||
@@ -1392,7 +1384,7 @@ async def test_token_authenticator_dict_noauth(app):
|
||||
[
|
||||
('admin', 'other', 200),
|
||||
('admin', 'missing', 404),
|
||||
('user', 'other', 403),
|
||||
('user', 'other', 404),
|
||||
('user', 'user', 200),
|
||||
],
|
||||
)
|
||||
@@ -1406,12 +1398,11 @@ async def test_token_list(app, as_user, for_user, status):
|
||||
if status != 200:
|
||||
return
|
||||
reply = r.json()
|
||||
assert sorted(reply) == ['api_tokens', 'oauth_tokens']
|
||||
assert sorted(reply) == ['api_tokens']
|
||||
assert len(reply['api_tokens']) == len(for_user_obj.api_tokens)
|
||||
assert all(token['user'] == for_user for token in reply['api_tokens'])
|
||||
assert all(token['user'] == for_user for token in reply['oauth_tokens'])
|
||||
# validate individual token ids
|
||||
for token in reply['api_tokens'] + reply['oauth_tokens']:
|
||||
for token in reply['api_tokens']:
|
||||
r = await api_request(
|
||||
app, 'users', for_user, 'tokens', token['id'], headers=headers
|
||||
)
|
||||
@@ -1443,8 +1434,8 @@ async def test_groups_list(app):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply == [
|
||||
{'kind': 'group', 'name': 'alphaflight', 'users': []},
|
||||
{'kind': 'group', 'name': 'betaflight', 'users': []},
|
||||
{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []},
|
||||
{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []},
|
||||
]
|
||||
|
||||
# Test offset for pagination
|
||||
@@ -1452,7 +1443,7 @@ async def test_groups_list(app):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert r.status_code == 200
|
||||
assert reply == [{'kind': 'group', 'name': 'betaflight', 'users': []}]
|
||||
assert reply == [{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []}]
|
||||
|
||||
r = await api_request(app, "groups?offset=10")
|
||||
r.raise_for_status()
|
||||
@@ -1464,7 +1455,7 @@ async def test_groups_list(app):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert r.status_code == 200
|
||||
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': []}]
|
||||
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}]
|
||||
|
||||
r = await api_request(app, "groups?limit=0")
|
||||
r.raise_for_status()
|
||||
@@ -1508,6 +1499,7 @@ async def test_group_get(app):
|
||||
'kind': 'group',
|
||||
'name': 'alphaflight',
|
||||
'users': ['sasquatch'],
|
||||
'roles': [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1619,8 +1611,10 @@ async def test_get_services(app, mockservice_url):
|
||||
services = r.json()
|
||||
assert services == {
|
||||
mockservice.name: {
|
||||
'kind': 'service',
|
||||
'name': mockservice.name,
|
||||
'admin': True,
|
||||
'roles': ['admin'],
|
||||
'command': mockservice.command,
|
||||
'pid': mockservice.proc.pid,
|
||||
'prefix': mockservice.server.base_url,
|
||||
@@ -1629,7 +1623,6 @@ async def test_get_services(app, mockservice_url):
|
||||
'display': True,
|
||||
}
|
||||
}
|
||||
|
||||
r = await api_request(app, 'services', headers=auth_header(db, 'user'))
|
||||
assert r.status_code == 403
|
||||
|
||||
@@ -1644,8 +1637,10 @@ async def test_get_service(app, mockservice_url):
|
||||
|
||||
service = r.json()
|
||||
assert service == {
|
||||
'kind': 'service',
|
||||
'name': mockservice.name,
|
||||
'admin': True,
|
||||
'roles': ['admin'],
|
||||
'command': mockservice.command,
|
||||
'pid': mockservice.proc.pid,
|
||||
'prefix': mockservice.server.base_url,
|
||||
@@ -1653,7 +1648,6 @@ async def test_get_service(app, mockservice_url):
|
||||
'info': {},
|
||||
'display': True,
|
||||
}
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'services/%s' % mockservice.name,
|
||||
@@ -1673,7 +1667,7 @@ async def test_root_api(app):
|
||||
if app.internal_ssl:
|
||||
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
||||
kwargs["verify"] = app.internal_ssl_ca
|
||||
r = await async_requests.get(url, **kwargs)
|
||||
r = await api_request(app, bypass_proxy=True)
|
||||
r.raise_for_status()
|
||||
expected = {'version': jupyterhub.__version__}
|
||||
assert r.json() == expected
|
||||
@@ -1717,11 +1711,11 @@ async def test_update_activity_403(app, user, admin_user):
|
||||
data="{}",
|
||||
method="post",
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_update_activity_admin(app, user, admin_user):
|
||||
token = admin_user.new_api_token()
|
||||
token = admin_user.new_api_token(roles=['admin'])
|
||||
r = await api_request(
|
||||
app,
|
||||
"users/{}/activity".format(user.name),
|
||||
|
@@ -51,7 +51,7 @@ def test_raise_error_on_missing_specified_config():
|
||||
process = Popen(
|
||||
[sys.executable, '-m', 'jupyterhub', '--config', 'not-available.py']
|
||||
)
|
||||
# wait inpatiently for the process to exit like we want it to
|
||||
# wait impatiently for the process to exit like we want it to
|
||||
for i in range(100):
|
||||
time.sleep(0.1)
|
||||
returncode = process.poll()
|
||||
|
@@ -36,7 +36,7 @@ def generate_old_db(env_dir, hub_version, db_url):
|
||||
check_call([env_py, populate_db, db_url])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hub_version', ['0.7.2', '0.8.1', '0.9.4'])
|
||||
@pytest.mark.parametrize('hub_version', ['1.0.0', "1.2.2", "1.3.0"])
|
||||
async def test_upgrade(tmpdir, hub_version):
|
||||
db_url = os.getenv('JUPYTERHUB_TEST_DB_URL')
|
||||
if db_url:
|
||||
|
@@ -52,10 +52,10 @@ async def test_default_server(app, named_servers):
|
||||
r.raise_for_status()
|
||||
|
||||
user_model = normalize_user(r.json())
|
||||
print(user_model)
|
||||
assert user_model == fill_user(
|
||||
{
|
||||
'name': username,
|
||||
'roles': ['user'],
|
||||
'auth_state': None,
|
||||
'server': user.url,
|
||||
'servers': {
|
||||
@@ -86,7 +86,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'], 'auth_state': None}
|
||||
)
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ async def test_create_named_server(app, named_servers):
|
||||
assert user_model == fill_user(
|
||||
{
|
||||
'name': username,
|
||||
'roles': ['user'],
|
||||
'auth_state': None,
|
||||
'servers': {
|
||||
servername: {
|
||||
@@ -142,7 +143,7 @@ async def test_delete_named_server(app, named_servers):
|
||||
username = 'donaar'
|
||||
user = add_user(app.db, app, name=username)
|
||||
assert user.allow_named_servers
|
||||
cookies = app.login_user(username)
|
||||
cookies = await app.login_user(username)
|
||||
servername = 'splugoth'
|
||||
r = await api_request(app, 'users', username, 'servers', servername, method='post')
|
||||
r.raise_for_status()
|
||||
@@ -159,7 +160,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}
|
||||
)
|
||||
# wrapper Spawner is gone
|
||||
assert servername not in user.spawners
|
||||
|
@@ -13,6 +13,7 @@ from tornado import gen
|
||||
from .. import crypto
|
||||
from .. import objects
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..emptyclass import EmptyClass
|
||||
from ..user import User
|
||||
from .mocking import MockSpawner
|
||||
@@ -220,6 +221,10 @@ async def test_spawn_fails(db):
|
||||
orm_user = orm.User(name='aeofel')
|
||||
db.add(orm_user)
|
||||
db.commit()
|
||||
def_roles = roles.get_default_roles()
|
||||
for role in def_roles:
|
||||
roles.create_role(db, role)
|
||||
roles.assign_default_roles(db, orm_user)
|
||||
|
||||
class BadSpawner(MockSpawner):
|
||||
async def start(self):
|
||||
@@ -244,10 +249,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 == []
|
||||
@@ -353,8 +360,9 @@ def test_user_delete_cascade(db):
|
||||
spawner.server = server = orm.Server()
|
||||
oauth_code = orm.OAuthCode(client=oauth_client, user=user)
|
||||
db.add(oauth_code)
|
||||
oauth_token = orm.OAuthAccessToken(
|
||||
client=oauth_client, user=user, grant_type=orm.GrantType.authorization_code
|
||||
oauth_token = orm.APIToken(
|
||||
oauth_client=oauth_client,
|
||||
user=user,
|
||||
)
|
||||
db.add(oauth_token)
|
||||
db.commit()
|
||||
@@ -375,7 +383,7 @@ def test_user_delete_cascade(db):
|
||||
assert_not_found(db, orm.Spawner, spawner_id)
|
||||
assert_not_found(db, orm.Server, server_id)
|
||||
assert_not_found(db, orm.OAuthCode, oauth_code_id)
|
||||
assert_not_found(db, orm.OAuthAccessToken, oauth_token_id)
|
||||
assert_not_found(db, orm.APIToken, oauth_token_id)
|
||||
|
||||
|
||||
def test_oauth_client_delete_cascade(db):
|
||||
@@ -389,12 +397,13 @@ def test_oauth_client_delete_cascade(db):
|
||||
# these should all be deleted automatically when the user goes away
|
||||
oauth_code = orm.OAuthCode(client=oauth_client, user=user)
|
||||
db.add(oauth_code)
|
||||
oauth_token = orm.OAuthAccessToken(
|
||||
client=oauth_client, user=user, grant_type=orm.GrantType.authorization_code
|
||||
oauth_token = orm.APIToken(
|
||||
oauth_client=oauth_client,
|
||||
user=user,
|
||||
)
|
||||
db.add(oauth_token)
|
||||
db.commit()
|
||||
assert user.oauth_tokens == [oauth_token]
|
||||
assert user.api_tokens == [oauth_token]
|
||||
|
||||
# record all of the ids
|
||||
oauth_code_id = oauth_code.id
|
||||
@@ -406,8 +415,8 @@ def test_oauth_client_delete_cascade(db):
|
||||
|
||||
# verify that everything gets deleted
|
||||
assert_not_found(db, orm.OAuthCode, oauth_code_id)
|
||||
assert_not_found(db, orm.OAuthAccessToken, oauth_token_id)
|
||||
assert user.oauth_tokens == []
|
||||
assert_not_found(db, orm.APIToken, oauth_token_id)
|
||||
assert user.api_tokens == []
|
||||
assert user.oauth_codes == []
|
||||
|
||||
|
||||
@@ -459,7 +468,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
|
||||
@@ -479,6 +488,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
|
||||
|
||||
@@ -507,32 +517,31 @@ def test_expiring_api_token(app, user):
|
||||
def test_expiring_oauth_token(app, user):
|
||||
db = app.db
|
||||
token = "abc123"
|
||||
now = orm.OAuthAccessToken.now
|
||||
now = orm.APIToken.now
|
||||
client = orm.OAuthClient(identifier="xxx", secret="yyy")
|
||||
db.add(client)
|
||||
orm_token = orm.OAuthAccessToken(
|
||||
orm_token = orm.APIToken(
|
||||
token=token,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
client=client,
|
||||
oauth_client=client,
|
||||
user=user,
|
||||
expires_at=now() + 30,
|
||||
expires_at=now() + timedelta(seconds=30),
|
||||
)
|
||||
db.add(orm_token)
|
||||
db.commit()
|
||||
|
||||
found = orm.OAuthAccessToken.find(db, token)
|
||||
found = orm.APIToken.find(db, token)
|
||||
assert found is orm_token
|
||||
# purge_expired doesn't delete non-expired
|
||||
orm.OAuthAccessToken.purge_expired(db)
|
||||
found = orm.OAuthAccessToken.find(db, token)
|
||||
orm.APIToken.purge_expired(db)
|
||||
found = orm.APIToken.find(db, token)
|
||||
assert found is orm_token
|
||||
|
||||
with mock.patch.object(orm.OAuthAccessToken, 'now', lambda: now() + 60):
|
||||
found = orm.OAuthAccessToken.find(db, token)
|
||||
with mock.patch.object(orm.APIToken, 'now', lambda: now() + timedelta(seconds=60)):
|
||||
found = orm.APIToken.find(db, token)
|
||||
assert found is None
|
||||
assert orm_token in db.query(orm.OAuthAccessToken)
|
||||
orm.OAuthAccessToken.purge_expired(db)
|
||||
assert orm_token not in db.query(orm.OAuthAccessToken)
|
||||
assert orm_token in db.query(orm.APIToken)
|
||||
orm.APIToken.purge_expired(db)
|
||||
assert orm_token not in db.query(orm.APIToken)
|
||||
|
||||
|
||||
def test_expiring_oauth_code(app, user):
|
||||
|
@@ -12,8 +12,10 @@ from tornado.escape import url_escape
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from .. import orm
|
||||
from .. import scopes
|
||||
from ..auth import Authenticator
|
||||
from ..handlers import BaseHandler
|
||||
from ..utils import url_path_join
|
||||
from ..utils import url_path_join as ujoin
|
||||
from .mocking import FalsyCallableFormSpawner
|
||||
from .mocking import FormSpawner
|
||||
@@ -21,6 +23,7 @@ from .test_api import next_event
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
from .utils import get_page
|
||||
from .utils import public_host
|
||||
from .utils import public_url
|
||||
@@ -869,8 +872,9 @@ async def test_oauth_token_page(app):
|
||||
user = app.users[orm.User.find(app.db, name)]
|
||||
client = orm.OAuthClient(identifier='token')
|
||||
app.db.add(client)
|
||||
oauth_token = orm.OAuthAccessToken(
|
||||
client=client, user=user, grant_type=orm.GrantType.authorization_code
|
||||
oauth_token = orm.APIToken(
|
||||
oauth_client=client,
|
||||
user=user,
|
||||
)
|
||||
app.db.add(oauth_token)
|
||||
app.db.commit()
|
||||
@@ -945,6 +949,62 @@ async def test_bad_oauth_get(app, params):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scopes, has_access",
|
||||
[
|
||||
(["users"], False),
|
||||
(["admin:users"], False),
|
||||
(["users", "admin:users", "admin:servers"], True),
|
||||
],
|
||||
)
|
||||
async def test_admin_page_access(app, scopes, has_access, create_user_with_scopes):
|
||||
user = create_user_with_scopes(*scopes)
|
||||
cookies = await app.login_user(user.name)
|
||||
r = await get_page("/admin", app, cookies=cookies)
|
||||
if has_access:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_oauth_page_scope_appearance(
|
||||
app, mockservice_url, create_user_with_scopes, create_temp_role
|
||||
):
|
||||
service_role = create_temp_role(
|
||||
[
|
||||
'self',
|
||||
'read:users!user=gawain',
|
||||
'read:tokens',
|
||||
'read:groups!group=mythos',
|
||||
]
|
||||
)
|
||||
service = mockservice_url
|
||||
user = create_user_with_scopes("access:services")
|
||||
oauth_client = (
|
||||
app.db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
.one()
|
||||
)
|
||||
oauth_client.allowed_roles = [service_role]
|
||||
app.db.commit()
|
||||
|
||||
s = AsyncSession()
|
||||
s.cookies = await app.login_user(user.name)
|
||||
url = url_path_join(public_url(app, service) + 'owhoami/?arg=x')
|
||||
r = await s.get(url)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
scopes_block = soup.find('form')
|
||||
for scope in service_role.scopes:
|
||||
base_scope, _, filter_ = scope.partition('!')
|
||||
scope_def = scopes.scope_definitions[base_scope]
|
||||
assert scope_def['description'] in scopes_block.text
|
||||
if filter_:
|
||||
kind, _, name = filter_.partition('=')
|
||||
assert kind in scopes_block.text
|
||||
assert name in scopes_block.text
|
||||
|
||||
|
||||
async def test_token_page(app):
|
||||
name = "cake"
|
||||
cookies = await app.login_user(name)
|
||||
|
1334
jupyterhub/tests/test_roles.py
Normal file
1334
jupyterhub/tests/test_roles.py
Normal file
File diff suppressed because it is too large
Load Diff
926
jupyterhub/tests/test_scopes.py
Normal file
926
jupyterhub/tests/test_scopes.py
Normal file
@@ -0,0 +1,926 @@
|
||||
"""Test scopes for API handlers"""
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from pytest import mark
|
||||
from tornado import web
|
||||
from tornado.httputil import HTTPServerRequest
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..handlers import BaseHandler
|
||||
from ..scopes import _check_scope_access
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..scopes import get_scopes_for
|
||||
from ..scopes import needs_scope
|
||||
from ..scopes import parse_scopes
|
||||
from ..scopes import Scope
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
from .utils import auth_header
|
||||
|
||||
|
||||
def get_handler_with_scopes(scopes):
|
||||
handler = mock.Mock(spec=BaseHandler)
|
||||
handler.parsed_scopes = parse_scopes(scopes)
|
||||
return handler
|
||||
|
||||
|
||||
def test_scope_constructor():
|
||||
user1 = 'george'
|
||||
user2 = 'michael'
|
||||
scope_list = [
|
||||
'users',
|
||||
'read:users!user={}'.format(user1),
|
||||
'read:users!user={}'.format(user2),
|
||||
]
|
||||
parsed_scopes = parse_scopes(scope_list)
|
||||
|
||||
assert 'read:users' in parsed_scopes
|
||||
assert parsed_scopes['users']
|
||||
assert set(parsed_scopes['read:users']['user']) == {user1, user2}
|
||||
|
||||
|
||||
def test_scope_precendence():
|
||||
scope_list = ['read:users!user=maeby', 'read:users']
|
||||
parsed_scopes = parse_scopes(scope_list)
|
||||
assert parsed_scopes['read:users'] == Scope.ALL
|
||||
|
||||
|
||||
def test_scope_check_present():
|
||||
handler = get_handler_with_scopes(['read:users'])
|
||||
assert _check_scope_access(handler, 'read:users')
|
||||
assert _check_scope_access(handler, 'read:users', user='maeby')
|
||||
|
||||
|
||||
def test_scope_check_not_present():
|
||||
handler = get_handler_with_scopes(['read:users!user=maeby'])
|
||||
assert _check_scope_access(handler, 'read:users')
|
||||
with pytest.raises(web.HTTPError):
|
||||
_check_scope_access(handler, 'read:users', user='gob')
|
||||
with pytest.raises(web.HTTPError):
|
||||
_check_scope_access(handler, 'read:users', user='gob', server='server')
|
||||
|
||||
|
||||
def test_scope_filters():
|
||||
handler = get_handler_with_scopes(
|
||||
['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
|
||||
)
|
||||
assert _check_scope_access(handler, 'read:users', group='bluth')
|
||||
assert _check_scope_access(handler, 'read:users', user='maeby')
|
||||
|
||||
|
||||
def test_scope_multiple_filters():
|
||||
handler = get_handler_with_scopes(['read:users!user=george_michael'])
|
||||
assert _check_scope_access(
|
||||
handler, 'read:users', user='george_michael', group='bluths'
|
||||
)
|
||||
|
||||
|
||||
def test_scope_parse_server_name():
|
||||
handler = get_handler_with_scopes(
|
||||
['servers!server=maeby/server1', 'read:users!user=maeby']
|
||||
)
|
||||
assert _check_scope_access(handler, 'servers', user='maeby', server='server1')
|
||||
|
||||
|
||||
class MockAPIHandler:
|
||||
def __init__(self):
|
||||
self.expanded_scopes = {'users'}
|
||||
self.parsed_scopes = {}
|
||||
self.request = mock.Mock(spec=HTTPServerRequest)
|
||||
self.request.path = '/path'
|
||||
|
||||
def set_scopes(self, *scopes):
|
||||
self.expanded_scopes = set(scopes)
|
||||
self.parsed_scopes = parse_scopes(self.expanded_scopes)
|
||||
|
||||
@needs_scope('users')
|
||||
def user_thing(self, user_name):
|
||||
return True
|
||||
|
||||
@needs_scope('servers')
|
||||
def server_thing(self, user_name, server_name):
|
||||
return True
|
||||
|
||||
@needs_scope('read:groups')
|
||||
def group_thing(self, group_name):
|
||||
return True
|
||||
|
||||
@needs_scope('read:services')
|
||||
def service_thing(self, service_name):
|
||||
return True
|
||||
|
||||
@needs_scope('users')
|
||||
def other_thing(self, non_filter_argument):
|
||||
# Rely on inner vertical filtering
|
||||
return True
|
||||
|
||||
@needs_scope('users')
|
||||
@needs_scope('read:services')
|
||||
def secret_thing(self):
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_handler():
|
||||
obj = MockAPIHandler()
|
||||
return obj
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"scopes, method, arguments, is_allowed",
|
||||
[
|
||||
(['users'], 'user_thing', ('user',), True),
|
||||
(['users'], 'user_thing', ('michael',), True),
|
||||
([''], 'user_thing', ('michael',), False),
|
||||
(['read:users'], 'user_thing', ('gob',), False),
|
||||
(['read:users'], 'user_thing', ('michael',), False),
|
||||
(['users!user=george'], 'user_thing', ('george',), True),
|
||||
(['users!user=george'], 'user_thing', ('fake_user',), False),
|
||||
(['users!user=george'], 'user_thing', ('oscar',), False),
|
||||
(['users!user=george', 'users!user=oscar'], 'user_thing', ('oscar',), True),
|
||||
(['servers'], 'server_thing', ('user1', 'server_1'), True),
|
||||
(['servers'], 'server_thing', ('user1', ''), True),
|
||||
(['servers'], 'server_thing', ('user1', None), True),
|
||||
(
|
||||
['servers!server=maeby/bluth'],
|
||||
'server_thing',
|
||||
('maeby', 'bluth'),
|
||||
True,
|
||||
),
|
||||
(['servers!server=maeby/bluth'], 'server_thing', ('gob', 'bluth'), False),
|
||||
(
|
||||
['servers!server=maeby/bluth'],
|
||||
'server_thing',
|
||||
('maybe', 'bluth2'),
|
||||
False,
|
||||
),
|
||||
(['read:services'], 'service_thing', ('service1',), True),
|
||||
(
|
||||
['users!user=george', 'read:groups!group=bluths'],
|
||||
'group_thing',
|
||||
('bluths',),
|
||||
True,
|
||||
),
|
||||
(
|
||||
['users!user=george', 'read:groups!group=bluths'],
|
||||
'group_thing',
|
||||
('george',),
|
||||
False,
|
||||
),
|
||||
(
|
||||
['groups!group=george', 'read:groups!group=bluths'],
|
||||
'group_thing',
|
||||
('george',),
|
||||
False,
|
||||
),
|
||||
(['users'], 'other_thing', ('gob',), True),
|
||||
(['read:users'], 'other_thing', ('gob',), False),
|
||||
(['users!user=gob'], 'other_thing', ('gob',), True),
|
||||
(['users!user=gob'], 'other_thing', ('maeby',), True),
|
||||
],
|
||||
)
|
||||
def test_scope_method_access(mock_handler, scopes, method, arguments, is_allowed):
|
||||
mock_handler.current_user = mock.Mock(name=arguments[0])
|
||||
mock_handler.set_scopes(*scopes)
|
||||
api_call = getattr(mock_handler, method)
|
||||
if is_allowed:
|
||||
assert api_call(*arguments)
|
||||
else:
|
||||
with pytest.raises(web.HTTPError):
|
||||
api_call(*arguments)
|
||||
|
||||
|
||||
def test_double_scoped_method_succeeds(mock_handler):
|
||||
mock_handler.current_user = mock.Mock(name='lucille')
|
||||
mock_handler.set_scopes('users', 'read:services')
|
||||
mock_handler.parsed_scopes = parse_scopes(mock_handler.expanded_scopes)
|
||||
assert mock_handler.secret_thing()
|
||||
|
||||
|
||||
def test_double_scoped_method_denials(mock_handler):
|
||||
mock_handler.current_user = mock.Mock(name='lucille2')
|
||||
mock_handler.set_scopes('users', 'read:groups')
|
||||
with pytest.raises(web.HTTPError):
|
||||
mock_handler.secret_thing()
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"user_name, in_group, status_code",
|
||||
[
|
||||
('martha', False, 200),
|
||||
('michael', True, 200),
|
||||
('gob', True, 200),
|
||||
('tobias', False, 404),
|
||||
('ann', False, 404),
|
||||
],
|
||||
)
|
||||
async def test_expand_groups(app, user_name, in_group, status_code):
|
||||
test_role = {
|
||||
'name': 'test',
|
||||
'description': '',
|
||||
'users': [user_name],
|
||||
'scopes': [
|
||||
'read:users!user=martha',
|
||||
'read:users!group=bluth',
|
||||
'read:groups',
|
||||
],
|
||||
}
|
||||
roles.create_role(app.db, test_role)
|
||||
user = add_user(app.db, name=user_name)
|
||||
group_name = 'bluth'
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
if in_group and user not in group.users:
|
||||
group.users.append(user)
|
||||
roles.update_roles(app.db, user, roles=['test'])
|
||||
roles.strip_role(app.db, user, 'user')
|
||||
app.db.commit()
|
||||
r = await api_request(
|
||||
app, 'users', user_name, headers=auth_header(app.db, user_name)
|
||||
)
|
||||
assert r.status_code == status_code
|
||||
app.db.delete(group)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
async def test_by_fake_user(app):
|
||||
user_name = 'shade'
|
||||
user = add_user(app.db, name=user_name)
|
||||
auth_ = auth_header(app.db, user_name)
|
||||
app.users.delete(user)
|
||||
app.db.commit()
|
||||
r = await api_request(app, 'users', headers=auth_)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
err_message = "No access to resources or resources not found"
|
||||
|
||||
|
||||
async def test_request_fake_user(app, create_user_with_scopes):
|
||||
fake_user = 'annyong'
|
||||
user = create_user_with_scopes('read:users!group=stuff')
|
||||
r = await api_request(
|
||||
app, 'users', fake_user, headers=auth_header(app.db, user.name)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
# Consistency between no user and user not accessible
|
||||
assert r.json()['message'] == err_message
|
||||
|
||||
|
||||
async def test_refuse_exceeding_token_permissions(
|
||||
app, create_user_with_scopes, create_temp_role
|
||||
):
|
||||
user = create_user_with_scopes('self')
|
||||
user.new_api_token()
|
||||
create_temp_role(['admin:users'], 'exceeding_role')
|
||||
with pytest.raises(ValueError):
|
||||
roles.update_roles(app.db, entity=user.api_tokens[0], roles=['exceeding_role'])
|
||||
|
||||
|
||||
async def test_exceeding_user_permissions(
|
||||
app, create_user_with_scopes, create_temp_role
|
||||
):
|
||||
user = create_user_with_scopes('read:users:groups')
|
||||
api_token = user.new_api_token()
|
||||
orm_api_token = orm.APIToken.find(app.db, token=api_token)
|
||||
create_temp_role(['read:users'], 'reader_role')
|
||||
roles.grant_role(app.db, orm_api_token, rolename='reader_role')
|
||||
headers = {'Authorization': 'token %s' % api_token}
|
||||
r = await api_request(app, 'users', headers=headers)
|
||||
assert r.status_code == 200
|
||||
keys = {key for user in r.json() for key in user.keys()}
|
||||
assert 'groups' in keys
|
||||
assert 'last_activity' not in keys
|
||||
|
||||
|
||||
async def test_user_service_separation(app, mockservice_url, create_temp_role):
|
||||
name = mockservice_url.name
|
||||
user = add_user(app.db, name=name)
|
||||
|
||||
create_temp_role(['read:users'], 'reader_role')
|
||||
create_temp_role(['read:users:groups'], 'subreader_role')
|
||||
roles.update_roles(app.db, user, roles=['subreader_role'])
|
||||
roles.update_roles(app.db, mockservice_url.orm, roles=['reader_role'])
|
||||
user.roles.remove(orm.Role.find(app.db, name='user'))
|
||||
api_token = user.new_api_token()
|
||||
headers = {'Authorization': 'token %s' % api_token}
|
||||
r = await api_request(app, 'users', headers=headers)
|
||||
assert r.status_code == 200
|
||||
keys = {key for user in r.json() for key in user.keys()}
|
||||
assert 'groups' in keys
|
||||
assert 'last_activity' not in keys
|
||||
|
||||
|
||||
async def test_request_user_outside_group(app, create_user_with_scopes):
|
||||
outside_user = 'hello'
|
||||
user = create_user_with_scopes('read:users!group=stuff')
|
||||
add_user(app.db, name=outside_user)
|
||||
r = await api_request(
|
||||
app, 'users', outside_user, headers=auth_header(app.db, user.name)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
# Consistency between no user and user not accessible
|
||||
assert r.json()['message'] == err_message
|
||||
|
||||
|
||||
async def test_user_filter(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes(
|
||||
'read:users!user=lindsay', 'read:users!user=gob', 'read:users!user=oscar'
|
||||
)
|
||||
name_in_scope = {'lindsay', 'oscar', 'gob'}
|
||||
outside_scope = {'maeby', 'marta'}
|
||||
group_name = 'bluth'
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
for name in name_in_scope | outside_scope:
|
||||
group_user = add_user(app.db, name=name)
|
||||
if name not in group.users:
|
||||
group.users.append(group_user)
|
||||
app.db.commit()
|
||||
r = await api_request(app, 'users', headers=auth_header(app.db, user.name))
|
||||
assert r.status_code == 200
|
||||
result_names = {user['name'] for user in r.json()}
|
||||
assert result_names == name_in_scope
|
||||
app.db.delete(group)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
async def test_service_filter(app, create_user_with_scopes):
|
||||
services = [
|
||||
{'name': 'cull_idle', 'api_token': 'some-token'},
|
||||
{'name': 'user_service', 'api_token': 'some-other-token'},
|
||||
]
|
||||
for service in services:
|
||||
app.services.append(service)
|
||||
app.init_services()
|
||||
user = create_user_with_scopes('read:services!service=cull_idle')
|
||||
r = await api_request(app, 'services', headers=auth_header(app.db, user.name))
|
||||
assert r.status_code == 200
|
||||
service_names = set(r.json().keys())
|
||||
assert service_names == {'cull_idle'}
|
||||
|
||||
|
||||
async def test_user_filter_with_group(app, create_user_with_scopes):
|
||||
group_name = 'sitwell'
|
||||
user1 = create_user_with_scopes(f'read:users!group={group_name}')
|
||||
user2 = create_user_with_scopes('self')
|
||||
external_user = create_user_with_scopes('self')
|
||||
name_set = {user1.name, user2.name}
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
for user in {user1, user2}:
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
|
||||
r = await api_request(app, 'users', headers=auth_header(app.db, user1.name))
|
||||
assert r.status_code == 200
|
||||
result_names = {user['name'] for user in r.json()}
|
||||
assert result_names == name_set
|
||||
assert external_user.name not in result_names
|
||||
app.db.delete(group)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
async def test_group_scope_filter(app, create_user_with_scopes):
|
||||
in_groups = {'sitwell', 'bluth'}
|
||||
out_groups = {'austero'}
|
||||
user = create_user_with_scopes(
|
||||
*(f'read:groups!group={group}' for group in in_groups)
|
||||
)
|
||||
for group_name in in_groups | out_groups:
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
app.db.commit()
|
||||
r = await api_request(app, 'groups', headers=auth_header(app.db, user.name))
|
||||
assert r.status_code == 200
|
||||
result_names = {user['name'] for user in r.json()}
|
||||
assert result_names == in_groups
|
||||
for group_name in in_groups | out_groups:
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
app.db.delete(group)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
async def test_vertical_filter(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes('read:users:name')
|
||||
r = await api_request(app, 'users', headers=auth_header(app.db, user.name))
|
||||
assert r.status_code == 200
|
||||
allowed_keys = {'name', 'kind', 'admin'}
|
||||
assert set([key for user in r.json() for key in user.keys()]) == allowed_keys
|
||||
|
||||
|
||||
async def test_stacked_vertical_filter(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes('read:users:activity', 'read:users:groups')
|
||||
r = await api_request(app, 'users', headers=auth_header(app.db, user.name))
|
||||
assert r.status_code == 200
|
||||
allowed_keys = {'name', 'kind', 'groups', 'last_activity'}
|
||||
result_model = set([key for user in r.json() for key in user.keys()])
|
||||
assert result_model == allowed_keys
|
||||
|
||||
|
||||
async def test_cross_filter(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes('read:users:activity', 'self')
|
||||
new_users = {'britta', 'jeff', 'annie'}
|
||||
for new_user_name in new_users:
|
||||
add_user(app.db, name=new_user_name)
|
||||
app.db.commit()
|
||||
r = await api_request(app, 'users', headers=auth_header(app.db, user.name))
|
||||
assert r.status_code == 200
|
||||
restricted_keys = {'name', 'kind', 'last_activity'}
|
||||
key_in_full_model = 'created'
|
||||
for model_user in r.json():
|
||||
if model_user['name'] == user.name:
|
||||
assert key_in_full_model in model_user
|
||||
else:
|
||||
assert set(model_user.keys()) == restricted_keys
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"kind, has_user_scopes",
|
||||
[
|
||||
('users', True),
|
||||
('services', False),
|
||||
],
|
||||
)
|
||||
async def test_metascope_self_expansion(
|
||||
app, kind, has_user_scopes, create_user_with_scopes, create_service_with_scopes
|
||||
):
|
||||
if kind == 'users':
|
||||
orm_obj = create_user_with_scopes('self').orm_user
|
||||
else:
|
||||
orm_obj = create_service_with_scopes('self')
|
||||
# test expansion of user/service scopes
|
||||
scopes = roles.expand_roles_to_scopes(orm_obj)
|
||||
assert bool(scopes) == has_user_scopes
|
||||
|
||||
# test expansion of token scopes
|
||||
orm_obj.new_api_token()
|
||||
token_scopes = get_scopes_for(orm_obj.api_tokens[0])
|
||||
assert bool(token_scopes) == has_user_scopes
|
||||
|
||||
|
||||
async def test_metascope_all_expansion(app, create_user_with_scopes):
|
||||
user = create_user_with_scopes('self')
|
||||
user.new_api_token()
|
||||
token = user.api_tokens[0]
|
||||
# Check 'all' expansion
|
||||
token_scope_set = get_scopes_for(token)
|
||||
user_scope_set = get_scopes_for(user)
|
||||
assert user_scope_set == token_scope_set
|
||||
|
||||
# Check no roles means no permissions
|
||||
token.roles.clear()
|
||||
app.db.commit()
|
||||
token_scope_set = get_scopes_for(token)
|
||||
assert not token_scope_set
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"scopes, can_stop ,num_servers, keys_in, keys_out",
|
||||
[
|
||||
(['read:servers!user=almond'], False, 2, {'name'}, {'state'}),
|
||||
(['admin:users', 'read:users'], False, 0, set(), set()),
|
||||
(
|
||||
['read:servers!group=nuts', 'servers'],
|
||||
True,
|
||||
2,
|
||||
{'name'},
|
||||
{'state'},
|
||||
),
|
||||
(
|
||||
['admin:server_state', 'read:servers'],
|
||||
False,
|
||||
2,
|
||||
{'name', 'state'},
|
||||
set(),
|
||||
),
|
||||
(
|
||||
[
|
||||
'read:servers!server=almond/bianca',
|
||||
'admin:server_state!server=almond/bianca',
|
||||
],
|
||||
False,
|
||||
1,
|
||||
{'name', 'state'},
|
||||
set(),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_server_state_access(
|
||||
app,
|
||||
create_user_with_scopes,
|
||||
create_service_with_scopes,
|
||||
scopes,
|
||||
can_stop,
|
||||
num_servers,
|
||||
keys_in,
|
||||
keys_out,
|
||||
):
|
||||
with mock.patch.dict(
|
||||
app.tornado_settings,
|
||||
{'allow_named_servers': True, 'named_server_limit_per_user': 2},
|
||||
):
|
||||
user = create_user_with_scopes('self', name='almond')
|
||||
group_name = 'nuts'
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
server_names = ['bianca', 'terry']
|
||||
for server_name in server_names:
|
||||
await api_request(
|
||||
app, 'users', user.name, 'servers', server_name, method='post'
|
||||
)
|
||||
service = create_service_with_scopes(*scopes)
|
||||
api_token = service.new_api_token()
|
||||
headers = {'Authorization': 'token %s' % api_token}
|
||||
r = await api_request(app, 'users', user.name, headers=headers)
|
||||
r.raise_for_status()
|
||||
user_model = r.json()
|
||||
if num_servers:
|
||||
assert 'servers' in user_model
|
||||
server_models = user_model['servers']
|
||||
assert len(server_models) == num_servers
|
||||
for server, server_model in server_models.items():
|
||||
assert keys_in.issubset(server_model)
|
||||
assert keys_out.isdisjoint(server_model)
|
||||
else:
|
||||
assert 'servers' not in user_model
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
user.name,
|
||||
'servers',
|
||||
server_names[0],
|
||||
method='delete',
|
||||
headers=headers,
|
||||
)
|
||||
if can_stop:
|
||||
assert r.status_code == 204
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
app.db.delete(group)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"name, user_scopes, token_scopes, intersection_scopes",
|
||||
[
|
||||
(
|
||||
'no_filter',
|
||||
['users:activity'],
|
||||
['users:activity'],
|
||||
{'users:activity', 'read:users:activity'},
|
||||
),
|
||||
(
|
||||
'valid_own_filter',
|
||||
['read:users:activity'],
|
||||
['read:users:activity!user'],
|
||||
{'read:users:activity!user=temp_user_1'},
|
||||
),
|
||||
(
|
||||
'valid_other_filter',
|
||||
['read:users:activity'],
|
||||
['read:users:activity!user=otheruser'],
|
||||
{'read:users:activity!user=otheruser'},
|
||||
),
|
||||
(
|
||||
'no_filter_owner_filter',
|
||||
['read:users:activity!user'],
|
||||
['read:users:activity'],
|
||||
{'read:users:activity!user=temp_user_1'},
|
||||
),
|
||||
(
|
||||
'valid_own_filter',
|
||||
['read:users:activity!user'],
|
||||
['read:users:activity!user'],
|
||||
{'read:users:activity!user=temp_user_1'},
|
||||
),
|
||||
(
|
||||
'invalid_filter',
|
||||
['read:users:activity!user'],
|
||||
['read:users:activity!user=otheruser'],
|
||||
set(),
|
||||
),
|
||||
(
|
||||
'subscopes_cross_filter',
|
||||
['users!user=x'],
|
||||
['read:users:name'],
|
||||
{'read:users:name!user=x'},
|
||||
),
|
||||
(
|
||||
'multiple_user_filter',
|
||||
['users!user=x', 'users!user=y'],
|
||||
['read:users:name!user=x'],
|
||||
{'read:users:name!user=x'},
|
||||
),
|
||||
(
|
||||
'no_intersection_group_user',
|
||||
['users!group=y'],
|
||||
['users!user=x'],
|
||||
set(),
|
||||
),
|
||||
(
|
||||
'no_intersection_user_server',
|
||||
['servers!user=y'],
|
||||
['servers!server=x'],
|
||||
set(),
|
||||
),
|
||||
(
|
||||
'users_and_groups_both',
|
||||
['users!group=x', 'users!user=y'],
|
||||
['read:users:name!group=x', 'read:users!user=y'],
|
||||
{
|
||||
'read:users:name!group=x',
|
||||
'read:users!user=y',
|
||||
'read:users:name!user=y',
|
||||
'read:users:groups!user=y',
|
||||
'read:users:activity!user=y',
|
||||
},
|
||||
),
|
||||
(
|
||||
'users_and_groups_user_only',
|
||||
['users!group=x', 'users!user=y'],
|
||||
['read:users:name!group=z', 'read:users!user=y'],
|
||||
{
|
||||
'read:users!user=y',
|
||||
'read:users:name!user=y',
|
||||
'read:users:groups!user=y',
|
||||
'read:users:activity!user=y',
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_resolve_token_permissions(
|
||||
app,
|
||||
create_user_with_scopes,
|
||||
create_temp_role,
|
||||
name,
|
||||
user_scopes,
|
||||
token_scopes,
|
||||
intersection_scopes,
|
||||
):
|
||||
orm_user = create_user_with_scopes(*user_scopes).orm_user
|
||||
create_temp_role(token_scopes, 'active-posting')
|
||||
api_token = orm_user.new_api_token(roles=['active-posting'])
|
||||
orm_api_token = orm.APIToken.find(app.db, token=api_token)
|
||||
|
||||
# get expanded !user filter scopes for check
|
||||
user_scopes = roles.expand_roles_to_scopes(orm_user)
|
||||
token_scopes = roles.expand_roles_to_scopes(orm_api_token)
|
||||
|
||||
token_retained_scopes = get_scopes_for(orm_api_token)
|
||||
|
||||
assert token_retained_scopes == intersection_scopes
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"scopes, model_keys",
|
||||
[
|
||||
(
|
||||
{'read:services'},
|
||||
{
|
||||
'command',
|
||||
'name',
|
||||
'kind',
|
||||
'info',
|
||||
'display',
|
||||
'pid',
|
||||
'admin',
|
||||
'prefix',
|
||||
'url',
|
||||
},
|
||||
),
|
||||
(
|
||||
{'read:roles:services', 'read:services:name'},
|
||||
{'name', 'kind', 'roles', 'admin'},
|
||||
),
|
||||
({'read:services:name'}, {'name', 'kind', 'admin'}),
|
||||
],
|
||||
)
|
||||
async def test_service_model_filtering(
|
||||
app, scopes, model_keys, create_user_with_scopes, create_service_with_scopes
|
||||
):
|
||||
user = create_user_with_scopes(*scopes, name='teddy')
|
||||
service = create_service_with_scopes()
|
||||
r = await api_request(
|
||||
app, 'services', service.name, headers=auth_header(app.db, user.name)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert model_keys == r.json().keys()
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"scopes, model_keys",
|
||||
[
|
||||
(
|
||||
{'read:groups'},
|
||||
{
|
||||
'name',
|
||||
'kind',
|
||||
'users',
|
||||
},
|
||||
),
|
||||
(
|
||||
{'read:roles:groups', 'read:groups:name'},
|
||||
{'name', 'kind', 'roles'},
|
||||
),
|
||||
({'read:groups:name'}, {'name', 'kind'}),
|
||||
],
|
||||
)
|
||||
async def test_group_model_filtering(
|
||||
app, scopes, model_keys, create_user_with_scopes, create_service_with_scopes
|
||||
):
|
||||
user = create_user_with_scopes(*scopes, name='teddy')
|
||||
group_name = 'baker_street'
|
||||
group = orm.Group.find(app.db, name=group_name)
|
||||
if not group:
|
||||
group = orm.Group(name=group_name)
|
||||
app.db.add(group)
|
||||
app.db.commit()
|
||||
r = await api_request(
|
||||
app, 'groups', group_name, headers=auth_header(app.db, user.name)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert model_keys == r.json().keys()
|
||||
app.db.delete(group)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
async def test_roles_access(app, create_service_with_scopes, create_user_with_scopes):
|
||||
user = add_user(app.db, name='miranda')
|
||||
read_user = create_user_with_scopes('read:roles:users')
|
||||
r = await api_request(
|
||||
app, 'users', user.name, headers=auth_header(app.db, read_user.name)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
model_keys = {'kind', 'name', 'roles', 'admin'}
|
||||
assert model_keys == r.json().keys()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"left, right, expected, should_warn",
|
||||
[
|
||||
(set(), set(), set(), False),
|
||||
(set(), set(["users"]), set(), False),
|
||||
# no warning if users and groups only on the same side
|
||||
(
|
||||
set(["users!user=x", "users!group=y"]),
|
||||
set([]),
|
||||
set([]),
|
||||
False,
|
||||
),
|
||||
# no warning if users are on both sizes
|
||||
(
|
||||
set(["users!user=x", "users!user=y", "users!group=y"]),
|
||||
set(["users!user=x"]),
|
||||
set(["users!user=x"]),
|
||||
False,
|
||||
),
|
||||
# no warning if users and groups are both defined
|
||||
# on both sides
|
||||
(
|
||||
set(["users!user=x", "users!group=y"]),
|
||||
set(["users!user=x", "users!group=y", "users!user=z"]),
|
||||
set(["users!user=x", "users!group=y"]),
|
||||
False,
|
||||
),
|
||||
# warn if there's a user on one side and a group on the other
|
||||
# which *may* intersect
|
||||
(
|
||||
set(["users!group=y", "users!user=z"]),
|
||||
set(["users!user=x"]),
|
||||
set([]),
|
||||
True,
|
||||
),
|
||||
# same for group->server
|
||||
(
|
||||
set(["users!group=y", "users!user=z"]),
|
||||
set(["users!server=x/y"]),
|
||||
set([]),
|
||||
True,
|
||||
),
|
||||
# this one actually shouldn't warn because server=x/y is under user=x,
|
||||
# but we don't need to overcomplicate things just for a warning
|
||||
(
|
||||
set(["users!group=y", "users!user=x"]),
|
||||
set(["users!server=x/y"]),
|
||||
set(["users!server=x/y"]),
|
||||
True,
|
||||
),
|
||||
# resolves server under user, without warning
|
||||
(
|
||||
set(["read:servers!user=abc"]),
|
||||
set(["read:servers!server=abc/xyz"]),
|
||||
set(["read:servers!server=abc/xyz"]),
|
||||
False,
|
||||
),
|
||||
# user->server, no match
|
||||
(
|
||||
set(["read:servers!user=abc"]),
|
||||
set(["read:servers!server=abcd/xyz"]),
|
||||
set([]),
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_intersect_expanded_scopes(left, right, expected, should_warn, recwarn):
|
||||
# run every test in both directions, to ensure symmetry of the inputs
|
||||
for a, b in [(left, right), (right, left)]:
|
||||
intersection = _intersect_expanded_scopes(set(left), set(right))
|
||||
assert intersection == set(expected)
|
||||
|
||||
if should_warn:
|
||||
assert len(recwarn) == 1
|
||||
else:
|
||||
assert len(recwarn) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"left, right, expected, groups",
|
||||
[
|
||||
(
|
||||
["users!group=gx"],
|
||||
["users!user=ux"],
|
||||
["users!user=ux"],
|
||||
{"gx": ["ux"]},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!user=nosuchuser"],
|
||||
[],
|
||||
{},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!server=nosuchuser/server"],
|
||||
[],
|
||||
{},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!server=ux/server"],
|
||||
["read:users!server=ux/server"],
|
||||
{"gx": ["ux"]},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!server=ux/server", "read:users!user=uy"],
|
||||
["read:users!server=ux/server"],
|
||||
{"gx": ["ux"], "gy": ["uy"]},
|
||||
),
|
||||
(
|
||||
["read:users!group=gy"],
|
||||
["read:users!server=ux/server", "read:users!user=uy"],
|
||||
["read:users!user=uy"],
|
||||
{"gx": ["ux"], "gy": ["uy"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_intersect_groups(request, db, left, right, expected, groups):
|
||||
if isinstance(left, str):
|
||||
left = set([left])
|
||||
if isinstance(right, str):
|
||||
right = set([right])
|
||||
|
||||
# if we have a db connection, we can actually resolve
|
||||
created = []
|
||||
for groupname, members in groups.items():
|
||||
group = orm.Group.find(db, name=groupname)
|
||||
if not group:
|
||||
group = orm.Group(name=groupname)
|
||||
db.add(group)
|
||||
created.append(group)
|
||||
db.commit()
|
||||
for username in members:
|
||||
user = orm.User.find(db, name=username)
|
||||
if user is None:
|
||||
user = orm.User(name=username)
|
||||
db.add(user)
|
||||
created.append(user)
|
||||
user.groups.append(group)
|
||||
db.commit()
|
||||
|
||||
def _cleanup():
|
||||
for obj in created:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
request.addfinalizer(_cleanup)
|
||||
|
||||
# run every test in both directions, to ensure symmetry of the inputs
|
||||
for a, b in [(left, right), (right, left)]:
|
||||
intersection = _intersect_expanded_scopes(set(left), set(right), db)
|
||||
assert intersection == set(expected)
|
@@ -3,12 +3,13 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from binascii import hexlify
|
||||
from contextlib import contextmanager
|
||||
from subprocess import Popen
|
||||
|
||||
from async_generator import asynccontextmanager
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from .. import orm
|
||||
from ..roles import update_roles
|
||||
from ..utils import exponential_backoff
|
||||
from ..utils import maybe_future
|
||||
from ..utils import random_port
|
||||
from ..utils import url_path_join
|
||||
@@ -51,11 +52,11 @@ async def test_managed_service(mockservice):
|
||||
assert proc.poll() is not None
|
||||
|
||||
# ensure Hub notices service is down and brings it back up:
|
||||
for i in range(20):
|
||||
if service.proc is not proc:
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(0.2)
|
||||
await exponential_backoff(
|
||||
lambda: service.proc is not proc,
|
||||
"Process was never replaced",
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
assert service.proc.pid != first_pid
|
||||
assert service.proc.poll() is None
|
||||
@@ -85,13 +86,20 @@ async def test_external_service(app):
|
||||
'admin': True,
|
||||
'url': env['JUPYTERHUB_SERVICE_URL'],
|
||||
'api_token': env['JUPYTERHUB_API_TOKEN'],
|
||||
'oauth_roles': ['user'],
|
||||
}
|
||||
]
|
||||
await maybe_future(app.init_services())
|
||||
await app.init_api_tokens()
|
||||
await app.proxy.add_all_services(app._service_map)
|
||||
await app.init_role_assignment()
|
||||
|
||||
service = app._service_map[name]
|
||||
assert service.oauth_available
|
||||
assert service.oauth_client is not None
|
||||
assert service.oauth_client.allowed_roles == [orm.Role.find(app.db, "user")]
|
||||
api_token = service.orm.api_tokens[0]
|
||||
update_roles(app.db, api_token, roles=['token'])
|
||||
url = public_url(app, service) + '/api/users'
|
||||
r = await async_requests.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
@@ -102,3 +110,51 @@ async def test_external_service(app):
|
||||
assert len(resp) >= 1
|
||||
assert isinstance(resp[0], dict)
|
||||
assert 'name' in resp[0]
|
||||
|
||||
|
||||
async def test_external_services_without_api_token_set(app):
|
||||
"""
|
||||
This test was made to reproduce an error like this:
|
||||
|
||||
ValueError: Tokens must be at least 8 characters, got ''
|
||||
|
||||
The error had the following stack trace in 1.4.1:
|
||||
|
||||
jupyterhub/app.py:2213: in init_api_tokens
|
||||
await self._add_tokens(self.service_tokens, kind='service')
|
||||
jupyterhub/app.py:2182: in _add_tokens
|
||||
obj.new_api_token(
|
||||
jupyterhub/orm.py:424: in new_api_token
|
||||
return APIToken.new(token=token, service=self, **kwargs)
|
||||
jupyterhub/orm.py:699: in new
|
||||
cls.check_token(db, token)
|
||||
|
||||
This test also make _add_tokens receive a token_dict that is buggy:
|
||||
|
||||
{"": "external_2"}
|
||||
|
||||
It turned out that whatever passes token_dict to _add_tokens failed to
|
||||
ignore service's api_tokens that were None, and instead passes them as blank
|
||||
strings.
|
||||
|
||||
It turned out that init_api_tokens was passing self.service_tokens, and that
|
||||
self.service_tokens had been populated with blank string tokens for external
|
||||
services registered with JupyterHub.
|
||||
"""
|
||||
name_1 = 'external_1'
|
||||
name_2 = 'external_2'
|
||||
async with external_service(app, name=name_1) as env_1, external_service(
|
||||
app, name=name_2
|
||||
) as env_2:
|
||||
app.services = [
|
||||
{
|
||||
'name': name_1,
|
||||
'url': "http://irrelevant",
|
||||
},
|
||||
{
|
||||
'name': name_2,
|
||||
'url': "http://irrelevant",
|
||||
},
|
||||
]
|
||||
await maybe_future(app.init_services())
|
||||
await app.init_api_tokens()
|
||||
|
@@ -1,33 +1,20 @@
|
||||
"""Tests for service authentication"""
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from binascii import hexlify
|
||||
from functools import partial
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
import requests_mock
|
||||
import pytest
|
||||
from pytest import raises
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.web import Application
|
||||
from tornado.web import authenticated
|
||||
from tornado.web import HTTPError
|
||||
from tornado.web import RequestHandler
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..services.auth import _ExpiringDict
|
||||
from ..services.auth import HubAuth
|
||||
from ..services.auth import HubAuthenticated
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_host
|
||||
from .mocking import public_url
|
||||
from .test_api import add_user
|
||||
from .utils import async_requests
|
||||
@@ -74,192 +61,29 @@ def test_expiring_dict():
|
||||
assert cache.get('key', 'default') == 'cached value'
|
||||
|
||||
|
||||
def test_hub_auth():
|
||||
auth = HubAuth(cookie_name='foo')
|
||||
mock_model = {'name': 'onyxia'}
|
||||
url = url_path_join(auth.api_url, "authorizations/cookie/foo/bar")
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, text=json.dumps(mock_model))
|
||||
user_model = auth.user_for_cookie('bar')
|
||||
assert user_model == mock_model
|
||||
# check cache
|
||||
user_model = auth.user_for_cookie('bar')
|
||||
assert user_model == mock_model
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, status_code=404)
|
||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
||||
assert user_model is None
|
||||
|
||||
# invalidate cache with timer
|
||||
mock_model = {'name': 'willow'}
|
||||
with monotonic_future, requests_mock.Mocker() as m:
|
||||
m.get(url, text=json.dumps(mock_model))
|
||||
user_model = auth.user_for_cookie('bar')
|
||||
assert user_model == mock_model
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, status_code=500)
|
||||
with raises(HTTPError) as exc_info:
|
||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(url, status_code=400)
|
||||
with raises(HTTPError) as exc_info:
|
||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
def test_hub_authenticated(request):
|
||||
auth = HubAuth(cookie_name='jubal')
|
||||
mock_model = {'name': 'jubalearly', 'groups': ['lions']}
|
||||
cookie_url = url_path_join(auth.api_url, "authorizations/cookie", auth.cookie_name)
|
||||
good_url = url_path_join(cookie_url, "early")
|
||||
bad_url = url_path_join(cookie_url, "late")
|
||||
|
||||
class TestHandler(HubAuthenticated, RequestHandler):
|
||||
hub_auth = auth
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
self.finish(self.get_current_user())
|
||||
|
||||
# start hub-authenticated service in a thread:
|
||||
port = 50505
|
||||
q = Queue()
|
||||
|
||||
def run():
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
app = Application([('/*', TestHandler)], login_url=auth.login_url)
|
||||
|
||||
http_server = HTTPServer(app)
|
||||
http_server.listen(port)
|
||||
loop = IOLoop.current()
|
||||
loop.add_callback(lambda: q.put(loop))
|
||||
loop.start()
|
||||
|
||||
t = Thread(target=run)
|
||||
t.start()
|
||||
|
||||
def finish_thread():
|
||||
loop.add_callback(loop.stop)
|
||||
t.join(timeout=30)
|
||||
assert not t.is_alive()
|
||||
|
||||
request.addfinalizer(finish_thread)
|
||||
|
||||
# wait for thread to start
|
||||
loop = q.get(timeout=10)
|
||||
|
||||
with requests_mock.Mocker(real_http=True) as m:
|
||||
# no cookie
|
||||
r = requests.get('http://127.0.0.1:%i' % port, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert auth.login_url in r.headers['Location']
|
||||
|
||||
# wrong cookie
|
||||
m.get(bad_url, status_code=404)
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'late'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 302
|
||||
assert auth.login_url in r.headers['Location']
|
||||
|
||||
# clear the cache because we are going to request
|
||||
# the same url again with a different result
|
||||
auth.cache.clear()
|
||||
|
||||
# upstream 403
|
||||
m.get(bad_url, status_code=403)
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'late'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 500
|
||||
|
||||
m.get(good_url, text=json.dumps(mock_model))
|
||||
|
||||
# no specific allowed user
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
# pass allowed user
|
||||
TestHandler.hub_users = {'jubalearly'}
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
# no pass allowed ser
|
||||
TestHandler.hub_users = {'kaylee'}
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# pass allowed group
|
||||
TestHandler.hub_groups = {'lions'}
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
|
||||
# no pass allowed group
|
||||
TestHandler.hub_groups = {'tigers'}
|
||||
r = requests.get(
|
||||
'http://127.0.0.1:%i' % port,
|
||||
cookies={'jubal': 'early'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_hubauth_cookie(app, mockservice_url):
|
||||
"""Test HubAuthenticated service with user cookies"""
|
||||
cookies = await app.login_user('badger')
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/', cookies=cookies
|
||||
)
|
||||
r.raise_for_status()
|
||||
print(r.text)
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {'name': 'badger', 'admin': False}
|
||||
|
||||
|
||||
async def test_hubauth_token(app, mockservice_url):
|
||||
async def test_hubauth_token(app, mockservice_url, create_user_with_scopes):
|
||||
"""Test HubAuthenticated service with user API tokens"""
|
||||
u = add_user(app.db, name='river')
|
||||
u = create_user_with_scopes("access:services")
|
||||
token = u.new_api_token()
|
||||
no_access_token = u.new_api_token(roles=[])
|
||||
app.db.commit()
|
||||
|
||||
# token without sufficient permission in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
headers={'Authorization': f'token {no_access_token}'},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# token in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
headers={'Authorization': 'token %s' % token},
|
||||
headers={'Authorization': f'token {token}'},
|
||||
)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {'name': 'river', 'admin': False}
|
||||
assert sub_reply == {'name': u.name, 'admin': False}
|
||||
|
||||
# token in ?token parameter
|
||||
r = await async_requests.get(
|
||||
@@ -268,7 +92,7 @@ async def test_hubauth_token(app, mockservice_url):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {'name': 'river', 'admin': False}
|
||||
assert sub_reply == {'name': u.name, 'admin': False}
|
||||
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
|
||||
@@ -281,34 +105,95 @@ async def test_hubauth_token(app, mockservice_url):
|
||||
assert path.endswith('/hub/login')
|
||||
|
||||
|
||||
async def test_hubauth_service_token(app, mockservice_url):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes, allowed",
|
||||
[
|
||||
(
|
||||
[
|
||||
"access:services",
|
||||
],
|
||||
True,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:services!service=$service",
|
||||
],
|
||||
True,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:services!service=other-service",
|
||||
],
|
||||
False,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:servers!user=$service",
|
||||
],
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_hubauth_service_token(request, app, mockservice_url, scopes, allowed):
|
||||
"""Test HubAuthenticated service with service API tokens"""
|
||||
|
||||
scopes = [scope.replace('$service', mockservice_url.name) for scope in scopes]
|
||||
|
||||
token = hexlify(os.urandom(5)).decode('utf8')
|
||||
name = 'test-api-service'
|
||||
app.service_tokens[token] = name
|
||||
await app.init_api_tokens()
|
||||
|
||||
orm_service = app.db.query(orm.Service).filter_by(name=name).one()
|
||||
role_name = "test-hubauth-service-token"
|
||||
|
||||
roles.create_role(
|
||||
app.db,
|
||||
{
|
||||
"name": role_name,
|
||||
"description": "role for test",
|
||||
"scopes": scopes,
|
||||
},
|
||||
)
|
||||
request.addfinalizer(lambda: roles.delete_role(app.db, role_name))
|
||||
roles.grant_role(app.db, orm_service, role_name)
|
||||
|
||||
# token in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
public_url(app, mockservice_url) + 'whoami/',
|
||||
headers={'Authorization': 'token %s' % token},
|
||||
allow_redirects=False,
|
||||
)
|
||||
service_model = {
|
||||
'kind': 'service',
|
||||
'name': name,
|
||||
'admin': False,
|
||||
'scopes': scopes,
|
||||
}
|
||||
if allowed:
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert reply == {'kind': 'service', 'name': name, 'admin': False}
|
||||
assert service_model.items() <= reply.items()
|
||||
assert not r.cookies
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
# token in ?token parameter
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=%s' % token
|
||||
public_url(app, mockservice_url) + 'whoami/?token=%s' % token
|
||||
)
|
||||
if allowed:
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert reply == {'kind': 'service', 'name': name, 'admin': False}
|
||||
assert service_model.items() <= reply.items()
|
||||
assert not r.cookies
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
|
||||
public_url(app, mockservice_url) + 'whoami/?token=no-such-token',
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
@@ -318,12 +203,55 @@ async def test_hubauth_service_token(app, mockservice_url):
|
||||
assert path.endswith('/hub/login')
|
||||
|
||||
|
||||
async def test_oauth_service(app, mockservice_url):
|
||||
@pytest.mark.parametrize(
|
||||
"client_allowed_roles, request_roles, expected_roles",
|
||||
[
|
||||
# allow empty roles
|
||||
([], [], []),
|
||||
# allow original 'identify' scope to map to no role
|
||||
([], ["identify"], []),
|
||||
# requesting roles outside client list doesn't work
|
||||
([], ["admin"], None),
|
||||
([], ["token"], None),
|
||||
# requesting nonexistent roles fails in the same way (no server error)
|
||||
([], ["nosuchrole"], None),
|
||||
# requesting exactly client allow list works
|
||||
(["user"], ["user"], ["user"]),
|
||||
# no explicit request, defaults to all
|
||||
(["token", "user"], [], ["token", "user"]),
|
||||
# explicit 'identify' maps to none
|
||||
(["token", "user"], ["identify"], []),
|
||||
# any item outside the list isn't allowed
|
||||
(["token", "user"], ["token", "server"], None),
|
||||
# requesting subset
|
||||
(["admin", "user"], ["user"], ["user"]),
|
||||
(["user", "token", "server"], ["token", "user"], ["token", "user"]),
|
||||
],
|
||||
)
|
||||
async def test_oauth_service_roles(
|
||||
app,
|
||||
mockservice_url,
|
||||
create_user_with_scopes,
|
||||
client_allowed_roles,
|
||||
request_roles,
|
||||
expected_roles,
|
||||
):
|
||||
service = mockservice_url
|
||||
oauth_client = (
|
||||
app.db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
.one()
|
||||
)
|
||||
oauth_client.allowed_roles = [
|
||||
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
|
||||
]
|
||||
app.db.commit()
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
name = 'link'
|
||||
user = create_user_with_scopes("access:services")
|
||||
roles.grant_role(app.db, user, "user")
|
||||
name = user.name
|
||||
s.cookies = await app.login_user(name)
|
||||
|
||||
r = await s.get(url)
|
||||
@@ -334,7 +262,18 @@ async def test_oauth_service(app, mockservice_url):
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# submit the oauth form to complete authorization
|
||||
r = await s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||
data = {}
|
||||
if request_roles:
|
||||
data["scopes"] = request_roles
|
||||
r = await s.post(r.url, data=data, headers={'Referer': r.url})
|
||||
if expected_roles is None:
|
||||
# expected failed auth, stop here
|
||||
# verify expected 'invalid scope' error, not server error
|
||||
dest_url, _, query = r.url.partition("?")
|
||||
assert dest_url == public_url(app, mockservice_url) + "oauth_callback"
|
||||
assert parse_qs(query).get("error") == ["invalid_scope"]
|
||||
assert r.status_code == 400
|
||||
return
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
@@ -348,7 +287,7 @@ async def test_oauth_service(app, mockservice_url):
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ('kind', 'name')}
|
||||
assert sub_reply == {'name': 'link', 'kind': 'user'}
|
||||
assert sub_reply == {'name': user.name, 'kind': 'user'}
|
||||
|
||||
# token-authenticated request to HubOAuth
|
||||
token = app.users[name].new_api_token()
|
||||
@@ -368,12 +307,122 @@ async def test_oauth_service(app, mockservice_url):
|
||||
assert reply['name'] == name
|
||||
|
||||
|
||||
async def test_oauth_cookie_collision(app, mockservice_url):
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, expect_success",
|
||||
[
|
||||
(["access:services"], True),
|
||||
(["access:services!service=$service"], True),
|
||||
(["access:services!service=other-service"], False),
|
||||
(["self"], False),
|
||||
([], False),
|
||||
],
|
||||
)
|
||||
async def test_oauth_access_scopes(
|
||||
app,
|
||||
mockservice_url,
|
||||
create_user_with_scopes,
|
||||
access_scopes,
|
||||
expect_success,
|
||||
):
|
||||
"""Check that oauth/authorize validates access scopes"""
|
||||
service = mockservice_url
|
||||
access_scopes = [s.replace("$service", service.name) for s in access_scopes]
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
user = create_user_with_scopes(*access_scopes)
|
||||
name = user.name
|
||||
s.cookies = await app.login_user(name)
|
||||
|
||||
r = await s.get(url)
|
||||
if not expect_success:
|
||||
assert r.status_code == 403
|
||||
return
|
||||
r.raise_for_status()
|
||||
# we should be looking at the oauth confirmation page
|
||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# submit the oauth form to complete authorization
|
||||
r = await s.post(r.url, headers={'Referer': r.url})
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
# verify oauth state cookie has been consumed
|
||||
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
||||
|
||||
# second request should be authenticated, which means no redirects
|
||||
r = await s.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ('kind', 'name')}
|
||||
assert sub_reply == {'name': name, 'kind': 'user'}
|
||||
|
||||
# revoke user access, should result in 403
|
||||
user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# reset session id to avoid cached response
|
||||
s.cookies.pop('jupyterhub-session-id')
|
||||
|
||||
r = await s.get(url, allow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token_roles, hits_page",
|
||||
[([], True), (['writer'], True), (['writer', 'reader'], False)],
|
||||
)
|
||||
async def test_oauth_page_hit(
|
||||
app,
|
||||
mockservice_url,
|
||||
create_user_with_scopes,
|
||||
create_temp_role,
|
||||
token_roles,
|
||||
hits_page,
|
||||
):
|
||||
test_roles = {
|
||||
'reader': create_temp_role(['read:users'], role_name='reader'),
|
||||
'writer': create_temp_role(['users:activity'], role_name='writer'),
|
||||
}
|
||||
service = mockservice_url
|
||||
user = create_user_with_scopes("access:services", "self")
|
||||
user.new_api_token()
|
||||
token = user.api_tokens[0]
|
||||
token.roles = [test_roles[t] for t in token_roles]
|
||||
|
||||
oauth_client = (
|
||||
app.db.query(orm.OAuthClient)
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
.one()
|
||||
)
|
||||
oauth_client.allowed_roles = list(test_roles.values())
|
||||
token.client_id = service.oauth_client_id
|
||||
app.db.commit()
|
||||
s = AsyncSession()
|
||||
s.cookies = await app.login_user(user.name)
|
||||
url = url_path_join(public_url(app, service) + 'owhoami/?arg=x')
|
||||
r = await s.get(url)
|
||||
r.raise_for_status()
|
||||
if hits_page:
|
||||
# hit auth page to confirm permissions
|
||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||
else:
|
||||
# skip auth page, permissions are granted
|
||||
assert r.status_code == 200
|
||||
assert r.url == url
|
||||
|
||||
|
||||
async def test_oauth_cookie_collision(app, mockservice_url, create_user_with_scopes):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/')
|
||||
print(url)
|
||||
s = AsyncSession()
|
||||
name = 'mypha'
|
||||
user = create_user_with_scopes("access:services", name=name)
|
||||
s.cookies = await app.login_user(name)
|
||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
@@ -426,7 +475,7 @@ async def test_oauth_cookie_collision(app, mockservice_url):
|
||||
assert state_cookies == []
|
||||
|
||||
|
||||
async def test_oauth_logout(app, mockservice_url):
|
||||
async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
|
||||
"""Verify that logout via the Hub triggers logout for oauth services
|
||||
|
||||
1. clears session id cookie
|
||||
@@ -440,15 +489,11 @@ async def test_oauth_logout(app, mockservice_url):
|
||||
# first request is only going to set login cookie
|
||||
s = AsyncSession()
|
||||
name = 'propha'
|
||||
app_user = add_user(app.db, app=app, name=name)
|
||||
user = create_user_with_scopes("access:services", name=name)
|
||||
|
||||
def auth_tokens():
|
||||
"""Return list of OAuth access tokens for the user"""
|
||||
return list(
|
||||
app.db.query(orm.OAuthAccessToken).filter(
|
||||
orm.OAuthAccessToken.user_id == app_user.id
|
||||
)
|
||||
)
|
||||
return list(app.db.query(orm.APIToken).filter_by(user_id=user.id))
|
||||
|
||||
# ensure we start empty
|
||||
assert auth_tokens() == []
|
||||
|
@@ -3,7 +3,10 @@ import sys
|
||||
from subprocess import check_output
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
import jupyterhub
|
||||
from .. import orm
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
from .mocking import StubSingleUserSpawner
|
||||
@@ -11,7 +14,33 @@ from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
|
||||
|
||||
async def test_singleuser_auth(app):
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, server_name, expect_success",
|
||||
[
|
||||
(["access:servers!group=$group"], "", True),
|
||||
(["access:servers!group=other-group"], "", False),
|
||||
(["access:servers"], "", True),
|
||||
(["access:servers"], "named", True),
|
||||
(["access:servers!user=$user"], "", True),
|
||||
(["access:servers!user=$user"], "named", True),
|
||||
(["access:servers!server=$server"], "", True),
|
||||
(["access:servers!server=$server"], "named-server", True),
|
||||
(["access:servers!server=$user/other"], "", False),
|
||||
(["access:servers!server=$user/other"], "some-name", False),
|
||||
(["access:servers!user=$other"], "", False),
|
||||
(["access:servers!user=$other"], "named", False),
|
||||
(["access:services"], "", False),
|
||||
(["self"], "named", False),
|
||||
([], "", False),
|
||||
],
|
||||
)
|
||||
async def test_singleuser_auth(
|
||||
app,
|
||||
create_user_with_scopes,
|
||||
access_scopes,
|
||||
server_name,
|
||||
expect_success,
|
||||
):
|
||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = StubSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
||||
@@ -19,10 +48,21 @@ async def test_singleuser_auth(app):
|
||||
# login, start the server
|
||||
cookies = await app.login_user('nandy')
|
||||
user = app.users['nandy']
|
||||
if not user.running:
|
||||
await user.spawn()
|
||||
await app.proxy.add_user(user)
|
||||
url = public_url(app, user)
|
||||
|
||||
group = orm.Group.find(app.db, name="visitors")
|
||||
if group is None:
|
||||
group = orm.Group(name="visitors")
|
||||
app.db.add(group)
|
||||
app.db.commit()
|
||||
if group not in user.groups:
|
||||
user.groups.append(group)
|
||||
app.db.commit()
|
||||
|
||||
if server_name not in user.spawners or not user.spawners[server_name].active:
|
||||
await user.spawn(server_name)
|
||||
await app.proxy.add_user(user, server_name)
|
||||
spawner = user.spawners[server_name]
|
||||
url = url_path_join(public_url(app, user), server_name)
|
||||
|
||||
# no cookies, redirects to login page
|
||||
r = await async_requests.get(url)
|
||||
@@ -40,7 +80,11 @@ async def test_singleuser_auth(app):
|
||||
assert (
|
||||
urlparse(r.url)
|
||||
.path.rstrip('/')
|
||||
.endswith(url_path_join('/user/nandy', user.spawner.default_url or "/tree"))
|
||||
.endswith(
|
||||
url_path_join(
|
||||
f'/user/{user.name}', spawner.name, spawner.default_url or "/tree"
|
||||
)
|
||||
)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -49,19 +93,40 @@ async def test_singleuser_auth(app):
|
||||
assert len(r.cookies) == 0
|
||||
|
||||
# accessing another user's server hits the oauth confirmation page
|
||||
access_scopes = [s.replace("$user", user.name) for s in access_scopes]
|
||||
access_scopes = [
|
||||
s.replace("$server", f"{user.name}/{server_name}") for s in access_scopes
|
||||
]
|
||||
access_scopes = [s.replace("$group", f"{group.name}") for s in access_scopes]
|
||||
other_user = create_user_with_scopes(*access_scopes, name="burgess")
|
||||
|
||||
cookies = await app.login_user('burgess')
|
||||
s = AsyncSession()
|
||||
s.cookies = cookies
|
||||
r = await s.get(url)
|
||||
assert urlparse(r.url).path.endswith('/oauth2/authorize')
|
||||
if not expect_success:
|
||||
# user isn't authorized, should raise 403
|
||||
assert r.status_code == 403
|
||||
return
|
||||
r.raise_for_status()
|
||||
# submit the oauth form to complete authorization
|
||||
r = await s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||
assert (
|
||||
urlparse(r.url)
|
||||
.path.rstrip('/')
|
||||
.endswith(url_path_join('/user/nandy', user.spawner.default_url or "/tree"))
|
||||
final_url = urlparse(r.url).path.rstrip('/')
|
||||
final_path = url_path_join(
|
||||
'/user/', user.name, spawner.name, spawner.default_url or "/tree"
|
||||
)
|
||||
# user isn't authorized, should raise 403
|
||||
assert final_url.endswith(final_path)
|
||||
r.raise_for_status()
|
||||
|
||||
# revoke user access, should result in 403
|
||||
other_user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# reset session id to avoid cached response
|
||||
s.cookies.pop('jupyterhub-session-id')
|
||||
|
||||
r = await s.get(r.url, allow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert 'burgess' in r.text
|
||||
|
||||
|
@@ -426,3 +426,9 @@ async def test_hub_connect_url(db):
|
||||
env["JUPYTERHUB_ACTIVITY_URL"]
|
||||
== "https://example.com/api/users/%s/activity" % name
|
||||
)
|
||||
|
||||
|
||||
async def test_spawner_oauth_roles(app):
|
||||
allowed_roles = ['lotsa', 'roles']
|
||||
spawner = new_spawner(app.db, oauth_roles=allowed_roles)
|
||||
assert spawner.oauth_roles == allowed_roles
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@@ -9,6 +10,8 @@ from certipy import Certipy
|
||||
from jupyterhub import metrics
|
||||
from jupyterhub import orm
|
||||
from jupyterhub.objects import Server
|
||||
from jupyterhub.roles import assign_default_roles
|
||||
from jupyterhub.roles import update_roles
|
||||
from jupyterhub.utils import url_path_join as ujoin
|
||||
|
||||
|
||||
@@ -78,14 +81,26 @@ def check_db_locks(func):
|
||||
"""
|
||||
|
||||
def new_func(app, *args, **kwargs):
|
||||
retval = func(app, *args, **kwargs)
|
||||
maybe_future = func(app, *args, **kwargs)
|
||||
|
||||
def _check(_=None):
|
||||
temp_session = app.session_factory()
|
||||
try:
|
||||
temp_session.execute('CREATE TABLE dummy (foo INT)')
|
||||
temp_session.execute('DROP TABLE dummy')
|
||||
finally:
|
||||
temp_session.close()
|
||||
|
||||
return retval
|
||||
async def await_then_check():
|
||||
result = await maybe_future
|
||||
_check()
|
||||
return result
|
||||
|
||||
if inspect.isawaitable(maybe_future):
|
||||
return await_then_check()
|
||||
else:
|
||||
_check()
|
||||
return maybe_future
|
||||
|
||||
return new_func
|
||||
|
||||
@@ -110,6 +125,11 @@ 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')
|
||||
if requested_roles:
|
||||
update_roles(db, entity=orm_user, roles=requested_roles)
|
||||
else:
|
||||
assign_default_roles(db, entity=orm_user)
|
||||
if app:
|
||||
return app.users[orm_user.id]
|
||||
else:
|
||||
@@ -137,7 +157,6 @@ async def api_request(
|
||||
else:
|
||||
base_url = public_url(app, path='hub')
|
||||
headers = kwargs.setdefault('headers', {})
|
||||
|
||||
if 'Authorization' not in headers and not noauth and 'cookies' not in kwargs:
|
||||
# make a copy to avoid modifying arg in-place
|
||||
kwargs['headers'] = h = {}
|
||||
|
@@ -560,7 +560,7 @@ class User:
|
||||
orm_server = orm.Server(base_url=base_url)
|
||||
db.add(orm_server)
|
||||
note = "Server at %s" % base_url
|
||||
api_token = self.new_api_token(note=note)
|
||||
api_token = self.new_api_token(note=note, roles=['server'])
|
||||
db.commit()
|
||||
|
||||
spawner = self.spawners[server_name]
|
||||
@@ -590,16 +590,19 @@ class User:
|
||||
client_id = spawner.oauth_client_id
|
||||
oauth_provider = self.settings.get('oauth_provider')
|
||||
if oauth_provider:
|
||||
oauth_client = oauth_provider.fetch_by_client_id(client_id)
|
||||
# create a new OAuth client + secret on every launch
|
||||
# containers that resume will be updated below
|
||||
oauth_provider.add_client(
|
||||
allowed_roles = spawner.oauth_roles
|
||||
if callable(allowed_roles):
|
||||
allowed_roles = allowed_roles(spawner)
|
||||
|
||||
oauth_client = oauth_provider.add_client(
|
||||
client_id,
|
||||
api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
allowed_roles=allowed_roles,
|
||||
description="Server at %s"
|
||||
% (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
spawner.orm_spawner.oauth_client = oauth_client
|
||||
db.commit()
|
||||
|
||||
# trigger pre-spawn hook on authenticator
|
||||
@@ -608,7 +611,7 @@ class User:
|
||||
spawner._start_pending = True
|
||||
|
||||
if authenticator:
|
||||
# pre_spawn_start can thow errors that can lead to a redirect loop
|
||||
# pre_spawn_start can throw errors that can lead to a redirect loop
|
||||
# if left uncaught (see https://github.com/jupyterhub/jupyterhub/issues/2683)
|
||||
await maybe_future(authenticator.pre_spawn_start(self, spawner))
|
||||
|
||||
|
@@ -253,9 +253,10 @@ def auth_decorator(check_auth):
|
||||
|
||||
def decorator(method):
|
||||
def decorated(self, *args, **kwargs):
|
||||
check_auth(self)
|
||||
check_auth(self, **kwargs)
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
# Perhaps replace with functools.wrap
|
||||
decorated.__name__ = method.__name__
|
||||
decorated.__doc__ = method.__doc__
|
||||
return decorated
|
||||
@@ -286,14 +287,6 @@ def authenticated_403(self):
|
||||
raise web.HTTPError(403)
|
||||
|
||||
|
||||
@auth_decorator
|
||||
def admin_only(self):
|
||||
"""Decorator for restricting access to admin users"""
|
||||
user = self.current_user
|
||||
if user is None or not user.admin:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
|
||||
@auth_decorator
|
||||
def metrics_authentication(self):
|
||||
"""Decorator for restricting access to metrics"""
|
||||
|
@@ -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
|
||||
|
@@ -8,36 +8,37 @@
|
||||
<h1 class="text-center">Authorize access</h1>
|
||||
|
||||
<h2>
|
||||
A service is attempting to authorize with your
|
||||
JupyterHub account
|
||||
An application is requesting authorization to access data associated with your JupyterHub account
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }})
|
||||
would like permission to identify you.
|
||||
{% if scopes == ["identify"] %}
|
||||
It will not be able to take actions on your behalf.
|
||||
{% if not role_names %}
|
||||
It will not be able to take actions on
|
||||
your behalf.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<h3>The application will be able to:</h3>
|
||||
<h3>This will grant the application permission to:</h3>
|
||||
<div>
|
||||
<form method="POST" action="">
|
||||
{% for scope in scopes %}
|
||||
{# these are the 'real' inputs to the form -#}
|
||||
{% for role_name in role_names %}
|
||||
<input type="hidden" name="scopes" value="{{ role_name }}"/>
|
||||
{% endfor %}
|
||||
|
||||
{% for scope_info in scope_descriptions %}
|
||||
<div class="checkbox input-group">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="scopes"
|
||||
checked="true"
|
||||
title="This authorization is required"
|
||||
disabled="disabled" {# disabled because it's required #}
|
||||
value="{{ scope }}"
|
||||
/>
|
||||
{# disabled checkbox isn't included in form, so this is the real one #}
|
||||
<input type="hidden" name="scopes" value="{{ scope }}"/>
|
||||
<input type="checkbox" name="raw-scopes" checked="true" title="This authorization is required"
|
||||
disabled="disabled"
|
||||
{# disabled because it's required #} />
|
||||
<span>
|
||||
{# TODO: use scope description when there's more than one #}
|
||||
See your JupyterHub username and group membership (read-only).
|
||||
{{ scope_info['description'] }}
|
||||
{% if scope_info['filter'] %}
|
||||
Applies to {{ scope_info['filter'] }}.
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -47,5 +48,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
Reference in New Issue
Block a user