Merge branch 'main' into allow_all

This commit is contained in:
Martynov Maxim
2021-07-05 14:43:02 +03:00
committed by GitHub
90 changed files with 7274 additions and 1335 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -20,7 +20,7 @@ fi
# Configure a set of databases in the database server for upgrade tests
set -x
for SUFFIX in '' _upgrade_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

View File

@@ -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."

View File

@@ -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

View File

@@ -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 users own resources _(metascope for users, resolves to (no_scope)
for services)_
all: Everything that the token-owning entity can access _(metascope for tokens)_
admin:users:
Read, write, create and delete users and their authentication state,
not including their servers or tokens.
admin:auth_state: Read a users authentication state.
users:
Read and write permissions to user models (excluding servers, tokens
and authentication state).
read:users:
Read user models (excluding including servers, tokens and authentication
state).
read:users:name: Read names of users.
read:users:groups: Read users group membership.
read:users:activity: Read time of last user activity.
read:roles: Read role assignments.
read:roles:users: Read user role assignments.
read:roles:services: Read service role assignments.
read:roles:groups: Read group role assignments.
users:activity: Update time of last user activity.
admin:servers: Read, start, stop, create and delete user servers and their state.
admin:server_state: Read and write users server state.
servers: Start and stop user servers.
read:servers:
Read users names and their server models (excluding the server
state).
tokens: Read, write, create and delete user tokens.
read:tokens: Read user tokens.
admin:groups: Read and write group information, create and delete groups.
groups:
Read and write group information, including adding/removing users to/from
groups.
read:groups: Read group models.
read:groups:name: Read group names.
read:services: Read service models.
read:services:name: Read service names.
read:hub: Read detailed information about the Hub.
access:servers: Access user servers via API or browser.
access:services: Access services via API or browser.
proxy:
Read information about the proxys routing table, sync the Hub with the
proxy and notify the Hub about a new proxy.
shutdown: Shutdown the hub.
security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope self) or have both (either can be used)?
- 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: lets not add a scope for this, lets 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

View File

@@ -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

View File

@@ -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 -------------------------------------------------------

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

View File

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

View 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
View 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
View 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 tokens permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role.
(define-role-target)=
## Defining Roles
Roles can be defined or modified in the configuration file as a list of dictionaries. An example:
% TODO: think about loading users into roles if membership has been changed via API.
% What should be the result?
```python
# in jupyterhub_config.py
c.JupyterHub.load_roles = [
{
'name': 'server-rights',
'description': 'Allows parties to start and stop user servers',
'scopes': ['servers'],
'users': ['alice', 'bob'],
'services': ['idle-culler'],
'groups': ['admin-group'],
}
]
```
The role `server-rights` now allows the starting and stopping of servers by any of the following:
- users `alice` and `bob`
- the service `idle-culler`
- any member of the `admin-group`.
```{attention}
Tokens cannot be assigned roles through role definition but may be assigned specific roles when requested via API (see {ref}`requesting-api-token-target`).
```
Another example:
```python
# in jupyterhub_config.py
c.JupyterHub.load_roles = [
{
'description': 'Read-only user models',
'name': 'reader',
'scopes': ['read:users'],
'services': ['external'],
'users': ['maria', 'joe']
}
]
```
The role `reader` allows users `maria` and `joe` and service `external` to read (but not modify) any users model.
```{admonition} Requirements
:class: warning
In a role definition, the `name` field is required, while all other fields are optional.\
**Role names must:**
- be 3 - 255 characters
- use ascii lowercase, numbers, 'unreserved' URL punctuation `-_.~`
- start with a letter
- end with letter or number.
`users`, `services`, and `groups` only accept objects that already exist in the database or are defined previously in the file.
It is not possible to implicitly add a new user to the database by defining a new role.
```
If no scopes are defined for _new role_, JupyterHub will raise a warning. Providing non-existing scopes will result in an error.
In case the role with a certain name already exists in the database, its definition and scopes will be overwritten. This holds true for all roles except the `admin` role, which cannot be overwritten; an error will be raised if trying to do so. All the role bearers permissions present in the definition will change accordingly.
(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
View 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.

View 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
```

View 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.

View 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.
```

View File

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

View File

@@ -26,3 +26,4 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
config-proxy
config-sudo
config-reference
oauth

View 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.

View File

@@ -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

View File

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

View File

@@ -13,7 +13,7 @@ if not api_token:
c.JupyterHub.services = [
{
'name': 'external-oauth',
'oauth_client_id': "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',
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
"...",
]
}
```

View File

@@ -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

View File

@@ -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(),
},
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"),

View File

@@ -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

View 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')

View File

@@ -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,
)
)

View File

@@ -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

View File

@@ -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)))

View File

@@ -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):

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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):

View File

@@ -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
)

View File

@@ -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.

View File

@@ -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()

View File

@@ -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():

View File

@@ -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,

View File

@@ -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):

View File

@@ -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
View 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
View 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 users 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 users 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 proxys 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

View File

@@ -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

View File

@@ -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"""

View File

@@ -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',

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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),

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View 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)

View File

@@ -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()

View File

@@ -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() == []

View File

@@ -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

View File

@@ -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

View File

@@ -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 = {}

View File

@@ -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))

View File

@@ -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"""

View File

@@ -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

View File

@@ -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 %}