Merge branch 'rbac' into rbac-fix-server-scope

This commit is contained in:
Min RK
2021-04-29 13:17:25 +02:00
committed by GitHub
40 changed files with 1020 additions and 196 deletions

View File

@@ -1,32 +0,0 @@
# Python CircleCI 2.0 configuration file
# Updating CircleCI configuration from v1 to v2
# Check https://circleci.com/docs/2.0/language-python/ for more details
#
version: 2
jobs:
build:
machine: true
steps:
- checkout
- run:
name: build images
command: |
docker build -t jupyterhub/jupyterhub .
docker build -t jupyterhub/jupyterhub-onbuild onbuild
docker build -t jupyterhub/jupyterhub:alpine -f dockerfiles/Dockerfile.alpine .
docker build -t jupyterhub/singleuser singleuser
- run:
name: smoke test jupyterhub
command: |
docker run --rm -it jupyterhub/jupyterhub jupyterhub --help
- run:
name: verify static files
command: |
docker run --rm -it -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py
# Tell CircleCI to use this workflow when it builds the site
workflows:
version: 2
default:
jobs:
- build

View File

@@ -66,3 +66,120 @@ jobs:
run: | run: |
pip install twine pip install twine
twine upload --skip-existing dist/* twine upload --skip-existing dist/*
publish-docker:
runs-on: ubuntu-20.04
services:
# So that we can test this in PRs/branches
local-registry:
image: registry:2
ports:
- 5000:5000
steps:
- name: Should we push this image to a public registry?
run: |
if [ "${{ startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/master') }}" = "true" ]; then
# Empty => Docker Hub
echo "REGISTRY=" >> $GITHUB_ENV
else
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
# Setup docker to build for multiple platforms, see:
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
- name: Set up Docker Buildx (for multi-arch builds)
uses: docker/setup-buildx-action@2a4b53665e15ce7d7049afb11ff1f70ff1610609 # associated tag: v1.1.2
with:
# Allows pushing to registry on localhost:5000
driver-opts: network=host
- name: Setup push rights to Docker Hub
# This was setup by...
# 1. Creating a Docker Hub service account "jupyterhubbot"
# 2. Creating a access token for the service account specific to this
# repository: https://hub.docker.com/settings/security
# 3. Making the account part of the "bots" team, and granting that team
# permissions to push to the relevant images:
# https://hub.docker.com/orgs/jupyterhub/teams/bots/permissions
# 4. Registering the username and token as a secret for this repo:
# https://github.com/jupyterhub/jupyterhub/settings/secrets/actions
if: env.REGISTRY != 'localhost:5000/'
run: |
docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags.
# E.g. 1.2.3 is expanded to Docker tags
# [{prefix}:1.2.3, {prefix}:1.2, {prefix}:1, {prefix}:latest] unless
# this is a backported tag in which case the newer tags aren't updated.
# For branches this will return the branch name.
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
- name: Get list of jupyterhub tags
id: jupyterhubtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref"
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
# tags parameter must be a string input so convert `gettags` JSON
# array into a comma separated list of tags
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
# jupyterhub-onbuild
- name: Get list of jupyterhub-onbuild tags
id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref"
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
context: onbuild
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
# jupyterhub-demo
- name: Get list of jupyterhub-demo tags
id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref"
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
context: demo-image
# linux/arm64 currently fails:
# ERROR: Could not build wheels for argon2-cffi which use PEP 517 and cannot be installed directly
# ERROR: executor failed running [/bin/sh -c python3 -m pip install notebook]: exit code: 1
platforms: linux/amd64
push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}

View File

@@ -222,3 +222,25 @@ jobs:
- name: Submit codecov report - name: Submit codecov report
run: | run: |
codecov codecov
docker-build:
runs-on: ubuntu-20.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: build images
run: |
docker build -t jupyterhub/jupyterhub .
docker build -t jupyterhub/jupyterhub-onbuild onbuild
docker build -t jupyterhub/jupyterhub:alpine -f dockerfiles/Dockerfile.alpine .
docker build -t jupyterhub/singleuser singleuser
- name: smoke test jupyterhub
run: |
docker run --rm -t jupyterhub/jupyterhub jupyterhub --help
- name: verify static files
run: |
docker run --rm -t -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py

View File

@@ -21,7 +21,7 @@
# your jupyterhub_config.py will be added automatically # your jupyterhub_config.py will be added automatically
# from your docker directory. # from your docker directory.
ARG BASE_IMAGE=ubuntu:focal-20200729@sha256:6f2fb2f9fb5582f8b587837afd6ea8f37d8d1d9e41168c90f410a6ef15fa8ce5 ARG BASE_IMAGE=ubuntu:focal-20200729
FROM $BASE_IMAGE AS builder FROM $BASE_IMAGE AS builder
USER root USER root

View File

@@ -4,9 +4,9 @@ alabaster_jupyterhub
# Temporary fix of #3021. Revert back to released autodoc-traits when # Temporary fix of #3021. Revert back to released autodoc-traits when
# 0.1.0 released. # 0.1.0 released.
https://github.com/jupyterhub/autodoc-traits/archive/75885ee24636efbfebfceed1043459715049cd84.zip https://github.com/jupyterhub/autodoc-traits/archive/75885ee24636efbfebfceed1043459715049cd84.zip
myst-parser
pydata-sphinx-theme pydata-sphinx-theme
pytablewriter>=0.56 pytablewriter>=0.56
recommonmark>=0.6
sphinx>=1.7 sphinx>=1.7
sphinx-copybutton sphinx-copybutton
sphinx-jsonschema sphinx-jsonschema

View File

@@ -3,7 +3,7 @@ swagger: "2.0"
info: info:
title: JupyterHub title: JupyterHub
description: The REST API for JupyterHub description: The REST API for JupyterHub
version: 1.2.0dev version: 1.4.0
license: license:
name: BSD-3-Clause name: BSD-3-Clause
schemes: [http, https] schemes: [http, https]
@@ -18,35 +18,41 @@ securityDefinitions:
authorizationUrl: "/hub/api/oauth2/authorize" # what are the absolute URIs here? is oauth2 correct here or shall we use just authorizations? 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" tokenUrl: "/hub/api/oauth2/token"
scopes: scopes:
all: Everything a user can do self: Metascope, grants access to user's own resources; resolves to (no scope) for services.
read:all: Read-only access to everything a user can read (also whoami handler) all: Metascope, valid for tokens only. Grants access to everything that the token's owning entity can do.
users: Grants access to managing users including reading users model, posting activity and starting/stoping users servers admin:users: Grants read, write, create and delete access to users and their authentication state but not their servers or tokens.
read:users: Read-only access to the above admin:users:auth_state: Grants access to users' authentication state only.
read:users!user=username: Read-only access to a single user's model users: Grants read and write permissions to users' models apart from servers, tokens and authentication state.
read:users:names: Read-only access to users' names users:activity: Grants access to read and post users' activity only.
read:users:groups: Read-only access to users' groups users:activity!user=username: Update a single user's activity (example horizontal filter).
read:users:activity: Read-only access to users' activity read:users: Read-only access to users' models apart from servers, tokens and authentication state.
read:users:activity!group=groupname: Read-only access to specific group's users' activity read:users!user=username: As above limited to a specific user (example horizontal filter).
read:users:servers: Read-only access to users' servers read:users:name: Read-only access to users' names.
users:activity!user=username: Update a user's activity read:users:roles: Read-only access to a list of users' roles names.
users:servers: Grants access to start/stop any server read:users:groups: Read-only access to a list of users' group names.
users:servers!server=servername: Limits the above to a specific server read:users:activity: Read-only access to users' activity.
users:tokens: Grants access to users' token (includes create/revoke a token) read:users:activity!group=groupname: Read-only access to specific group's users' activity (example horizontal filter).
read:users:tokens: Identify a user from a token admin:users:servers: Grants read, start/stop, create and delete permissions to users' servers and their state.
admin:users: Grants access to creating/removing users admin:users:server_state: Grants access to servers' state only.
admin:users:servers: Grants access to create/remove users' servers users:servers: Allows for starting/stopping users' servers in addition to read access to their models. Does not include the server state.
groups: Add/remove users from any group users:servers!server=servername: Limits the above to a specific server (example horizontal filter).
groups!group=groupname: Add/remove users from a specific group only read:users:servers: Read-only access to users' server models. Does not include the server state.
read:groups: Read-only access to groups users:tokens: Grants read, write, create and delete permissions to users' tokens.
admin:groups: Grants access to create/delete groups read:users:tokens: Read-only access to users' tokens.
read:services: Read-only access to services admin:groups: Grants read, write, create and delete access to groups.
read:hub: Read-only access to detailed information about JupyterHub groups: Grants read and write permissions to groups, including adding/removing users to/from groups.
proxy: Grants access to proxy's routing table, syncing and notifying about a new proxy groups!group=groupname: As above limited to a specific group only (example horizontal filter)
shutdown: Grants access to shutdown the Hub read:groups: Read-only access to groups.
security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope all) or have both (either can be used)? read:services: Read-only access to service models.
read:services:name: Read-only access to service names.
read:services:roles: Read-only access to a list of service roles names.
read:hub: Read-only access to detailed information about JupyterHub.
proxy: Allows for obtaining information about the proxy's routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy.
shutdown: Grants access to shutdown the Hub.
security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope self) or have both (either can be used)?
- token: [] - token: []
- oauth2: - oauth2:
- all - self
basePath: /hub/api basePath: /hub/api
produces: produces:
- application/json - application/json
@@ -116,8 +122,12 @@ paths:
summary: List users summary: List users
security: security:
- oauth2: - oauth2:
- users
- read:users - read:users
- read:users:name
- read:users:groups
- read:users:activity
- read:users:servers
#TODO: add admin:users:auth_state/server_state?
parameters: parameters:
- name: state - name: state
in: query in: query
@@ -173,9 +183,13 @@ paths:
summary: Get a user by name summary: Get a user by name
security: security:
- oauth2: - oauth2:
- users
- read:users - read:users
- read:users!user=username - read:users:name
- read:users:groups
- read:users:activity
- read:users:servers
- admin:users:auth_state
- admin:users:server_state
parameters: parameters:
- name: name - name: name
description: username description: username
@@ -208,7 +222,7 @@ paths:
description: Change a user's name or admin status description: Change a user's name or admin status
security: security:
- oauth2: - oauth2:
- users - admin:users
parameters: parameters:
- name: name - name: name
description: username description: username
@@ -255,8 +269,7 @@ paths:
actively using a server. actively using a server.
security: security:
- oauth2: - oauth2:
- users - users:activity
- users:activity!user=username
parameters: parameters:
- name: name - name: name
description: username description: username
@@ -311,7 +324,6 @@ paths:
summary: Start a user's single-user notebook server summary: Start a user's single-user notebook server
security: security:
- oauth2: - oauth2:
- users
- users:servers - users:servers
parameters: parameters:
- name: name - name: name
@@ -341,7 +353,6 @@ paths:
summary: Stop a user's server summary: Stop a user's server
security: security:
- oauth2: - oauth2:
- users
- users:servers - users:servers
parameters: parameters:
- name: name - name: name
@@ -359,9 +370,7 @@ paths:
summary: Start a user's single-user named-server notebook server summary: Start a user's single-user named-server notebook server
security: security:
- oauth2: - oauth2:
- users
- users:servers - users:servers
- users:servers!server=servername
parameters: parameters:
- name: name - name: name
description: username description: username
@@ -395,9 +404,7 @@ paths:
summary: Stop a user's named-server summary: Stop a user's named-server
security: security:
- oauth2: - oauth2:
- users
- users:servers - users:servers
- users:servers!server=servername
parameters: parameters:
- name: name - name: name
description: username description: username
@@ -437,7 +444,7 @@ paths:
summary: List tokens for the user summary: List tokens for the user
security: security:
- oauth2: - oauth2:
- users:tokens - read:users:tokens
responses: responses:
"200": "200":
description: The list of tokens description: The list of tokens
@@ -468,7 +475,9 @@ paths:
type: string type: string
description: A note attached to the token for future bookkeeping description: A note attached to the token for future bookkeeping
roles: roles:
type: list type: array
items:
type: string
description: A list of role names that the token should have description: A list of role names that the token should have
responses: responses:
"201": "201":
@@ -494,7 +503,7 @@ paths:
summary: Get the model for a token by id summary: Get the model for a token by id
security: security:
- oauth2: - oauth2:
- users:tokens - read:users:tokens
responses: responses:
"200": "200":
description: The info for the new token description: The info for the new token
@@ -513,8 +522,13 @@ paths:
summary: Return authenticated user's model summary: Return authenticated user's model
security: security:
- oauth2: - oauth2:
- all - read:users
- read:all - read:users:name
- read:users:groups
- read:users:activity
- read:users:servers
- admin:users:auth_state
- admin:users:server_state
responses: responses:
"200": "200":
description: The authenticated user's model is returned. description: The authenticated user's model is returned.
@@ -525,7 +539,6 @@ paths:
summary: List groups summary: List groups
security: security:
- oauth2: - oauth2:
- groups
- read:groups - read:groups
responses: responses:
"200": "200":
@@ -539,8 +552,6 @@ paths:
summary: Get a group by name summary: Get a group by name
security: security:
- oauth2: - oauth2:
- groups
- groups!group=groupname
- read:groups - read:groups
parameters: parameters:
- name: name - name: name
@@ -589,7 +600,6 @@ paths:
security: security:
- oauth2: - oauth2:
- groups - groups
- groups!group=groupname
parameters: parameters:
- name: name - name: name
description: group name description: group name
@@ -618,7 +628,6 @@ paths:
security: security:
- oauth2: - oauth2:
- groups - groups
- groups!group=groupname
parameters: parameters:
- name: name - name: name
description: group name description: group name
@@ -933,6 +942,10 @@ definitions:
description: The active servers for this user. description: The active servers for this user.
items: items:
$ref: "#/definitions/Server" $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: Server:
type: object type: object
properties: properties:
@@ -969,7 +982,7 @@ definitions:
description: UTC timestamp last-seen activity on this server. description: UTC timestamp last-seen activity on this server.
state: state:
type: object 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: user_options:
type: object 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.

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ extensions = [
'autodoc_traits', 'autodoc_traits',
'sphinx_copybutton', 'sphinx_copybutton',
'sphinx-jsonschema', 'sphinx-jsonschema',
'recommonmark', 'myst_parser',
] ]
# The master toctree document. # The master toctree document.
@@ -52,11 +52,6 @@ todo_include_todos = False
# Set the default role so we can use `foo` instead of ``foo`` # Set the default role so we can use `foo` instead of ``foo``
default_role = 'literal' default_role = 'literal'
# -- Source -------------------------------------------------------------
import recommonmark
from recommonmark.transform import AutoStructify
# -- Config ------------------------------------------------------------- # -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub from jupyterhub.app import JupyterHub
from docutils import nodes from docutils import nodes
@@ -111,9 +106,7 @@ class HelpAllDirective(SphinxDirective):
def setup(app): def setup(app):
app.add_config_value('recommonmark_config', {'enable_eval_rst': True}, True)
app.add_css_file('custom.css') app.add_css_file('custom.css')
app.add_transform(AutoStructify)
app.add_directive('jupyterhub-generate-config', ConfigDirective) app.add_directive('jupyterhub-generate-config', ConfigDirective)
app.add_directive('jupyterhub-help-all', HelpAllDirective) app.add_directive('jupyterhub-help-all', HelpAllDirective)

View File

@@ -1,6 +1,6 @@
# Frequently asked questions # 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`). 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 api/index
RBAC Reference
--------------
.. toctree::
:maxdepth: 2
rbac/index
Contributing Contributing
------------ ------------

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 `users: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
```

111
docs/source/rbac/roles.md Normal file
View File

@@ -0,0 +1,111 @@
# 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 similarly to the current admin status. 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 token's owner has.
- `server` role allows for posting activity of "itself" only. The scope is currently under development.
**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 and services** \
When a new user or service gets created, they are assigned their default role ( `user` or `admin`) if no custom role is requested, currently based on their admin status.
**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/tokens into roles if membership has been changed via API.
% What should be the result?
% What happens if a user is _removed_ from this list?
% Do they lose their role assignment or keep it?
```python
# in jupyterhub_config.py
c.JupyterHub.load_roles = [
{
'name': 'server-rights',
'description': 'Allows parties to start and stop user servers',
'scopes': ['users:servers'],
'users': ['alice', 'bob'],
'services': ['idle-culler'],
'groups': ['admin-group'],
'tokens': ['foo-6f6e65','bar-74776f']
}
]
```
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`
- requests using the tokens `foo-6f6e65` or `bar-74776f`.
```{attention}
The `foo-6f6e65` and `bar-74776f` tokens will be assigned the `server-rights` role only if their owner has the corresponding permissions, otherwise JupyterHub throws an error. See {ref}`resolving-roles-scopes-target` for more details on how this is assessed.
```
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.
If no scopes are defined for new role, JupyterHub will raise a warning. Providing non-existing scopes will result in an error.
Moreover, `users`, `services`, `groups`, and `tokens` only accept objects that already exist 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.
```
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.
Any previously defined role bearers for this role will remain the role bearers but their permissions will change if the role's permissions are overwritten. The newly defined bearers (in this case `maria` and `joe` and `external`) will be added to the existing ones.
Once a role is loaded, it remains in the database until explicitly deleting it through `delete_role()` function in `roles.py`. Default roles cannot be deleted.
Omitting the `c.JupyterHub.load_roles` or specifying different roles in the `jupyterhub_config.py` file on the next startup will not erase previously defined roles, nor their bearers.

125
docs/source/rbac/scopes.md Normal file
View File

@@ -0,0 +1,125 @@
# Scopes
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
- `<resource>` \
The `<resource>` scopes, such as `users` or `groups`, grant read and write permissions to the resource itself and all its sub-resources. E.g., the scope `users:servers` is included within 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.
+++
- `<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:servers` allows for accessing user servers only.
+++
- `<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.
- `users:servers!user=gerard` which grants the user access to their own servers without being able to create/delete any.
- `users:tokens!user=gerard` which allows the user to access, request and delete their own tokens.
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.
(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.
% TODO: Automatically generate this table from code when creating docs
Table 1. Available scopes and their hierarchy
| Scope name | Description |
| :--------- | :---------- |
| (no scope) | Allows for only identifying the owning entity. |
| `self` | Metascope, grants access to user's own resources; resolves to (no scope) for services. |
| `all` | Metascope, valid for tokens only. Grants access to everything that the token's owning entity can do. |
| `admin:users` | Grants read, write, create and delete access to users and their authentication state _but not their servers or tokens._ |
| &nbsp;&nbsp;&nbsp;`admin:users:auth_state` | Grants access to users' authentication state only. |
| &nbsp;&nbsp;&nbsp;`users` | Grants read and write permissions to users' models _apart from servers, tokens and authentication state_. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`users:activity` | Grants access to read and post users' activity only. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:users` | Read-only access to users' models _apart from servers, tokens and authentication state_. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:users:name` | Read-only access to users' names. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:users:roles` | Read-only access to a list of users' roles names. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:users:groups` | Read-only access to a list of users' group names. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:users:activity` | Read-only access to users' activity. |
| `admin:users:servers` | Grants read, start/stop, create and delete permissions to users' servers and their state. |
| &nbsp;&nbsp;&nbsp;`admin:users:server_state` | Grants access to servers' state only. |
| &nbsp;&nbsp;&nbsp;`users:servers` | Allows for starting/stopping users' servers in addition to read access to their models. _Does not include the server state_. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:users:servers` | Read-only access to users' server models. _Does not include the server state_. |
| `users:tokens` | Grants read, write, create and delete permissions to users' tokens. |
| &nbsp;&nbsp;&nbsp;`read:users:tokens` | Read-only access to users' tokens. |
| `admin:groups` | Grants read, write, create and delete access to groups. |
| &nbsp;&nbsp;&nbsp;`groups` | Grants read and write permissions to groups, including adding/removing users to/from groups. |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`read:groups` | Read-only access to groups. |
| `read:services` | Read-only access to service models. |
| &nbsp;&nbsp;&nbsp;`read:services:name` | Read-only access to service names. |
| &nbsp;&nbsp;&nbsp;`read:services:roles` | Read-only access to a list of service roles names. |
| `read:hub` | Read-only access to detailed information about the Hub. |
| `proxy` | Allows for obtaining information about the proxy's routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy. |
| `shutdown` | Grants access to shutdown the hub. |
```{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>` and `admin:<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,71 @@
# 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.
For example, one can change a token's role, and thereby its permissions, without the need to issue a new token (currently through `update_roles()` and `strip_role()` functions in `roles.py`, this will be eventually available through APIs).
Roles' and scopes' utilities can be found in `roles.py` and `scopes.py` modules.
(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 and, if applicable, comparing two sets of scopes. Both 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 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 in two ways:
1. through the `jupyterhub_config.py` file as described in the {ref}`define_role_target` section
2. 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, 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
```
```{note}
The above check is also performed when roles are requested for existing tokens, e.g., when adding tokens to {ref}`role definitions <define_role_target>` through the `jupyterhub_config.py`.
```
### 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 results 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 under development and will be available only via API/UI.
```
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", "users: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:
- `users:servers` to `admin:users: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. `users:servers`
3. `admin:users: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:users: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', 'users: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

@@ -1,3 +0,0 @@
# Docker Cloud build hooks
These are the hooks

View File

@@ -1,7 +0,0 @@
#!/bin/bash
set -exuo pipefail
# build jupyterhub-onbuild image
docker build --build-arg BASE_IMAGE=$DOCKER_REPO:$DOCKER_TAG -t ${DOCKER_REPO}-onbuild:$DOCKER_TAG onbuild
# build jupyterhub-demo image
docker build --build-arg BASE_IMAGE=${DOCKER_REPO}-onbuild:$DOCKER_TAG -t ${DOCKER_REPO}-demo:$DOCKER_TAG demo-image

View File

@@ -1,42 +0,0 @@
#!/bin/bash
set -exuo pipefail
export ONBUILD=${DOCKER_REPO}-onbuild
export DEMO=${DOCKER_REPO}-demo
export REPOS="${DOCKER_REPO} ${ONBUILD} ${DEMO}"
# push ONBUILD image
docker push $ONBUILD:$DOCKER_TAG
docker push $DEMO:$DOCKER_TAG
function get_hub_version() {
rm -f hub_version
docker run --rm -v $PWD:/version -u $(id -u) -i $DOCKER_REPO:$DOCKER_TAG sh -c 'jupyterhub --version > /version/hub_version'
hub_xyz=$(cat hub_version)
split=( ${hub_xyz//./ } )
hub_xy="${split[0]}.${split[1]}"
# add .dev on hub_xy so it's 1.0.dev
if [[ ! -z "${split[3]:-}" ]]; then
hub_xy="${hub_xy}.${split[3]}"
latest=0
else
latest=1
fi
}
get_hub_version
for repo in ${REPOS}; do
# when building master, push 0.9.0.dev as well
docker tag $repo:$DOCKER_TAG $repo:$hub_xyz
docker push $repo:$hub_xyz
# when building 0.9.x, push 0.9 as well
docker tag $repo:$DOCKER_TAG $repo:$hub_xy
docker push $repo:$hub_xy
# if building a stable release, tag latest as well
if [[ "$latest" == "1" ]]; then
docker tag $repo:$DOCKER_TAG $repo:latest
docker push $repo:latest
fi
done

View File

@@ -301,7 +301,6 @@ class UserTokenListAPIHandler(APIHandler):
self.write(json.dumps({'api_tokens': api_tokens})) self.write(json.dumps({'api_tokens': api_tokens}))
# @needs_scope('users:tokens') #Todo: needs internal scope checking
async def post(self, user_name): async def post(self, user_name):
body = self.get_json_body() or {} body = self.get_json_body() or {}
if not isinstance(body, dict): if not isinstance(body, dict):
@@ -330,13 +329,16 @@ class UserTokenListAPIHandler(APIHandler):
if requester is None: if requester is None:
# couldn't identify requester # couldn't identify requester
raise web.HTTPError(403) raise web.HTTPError(403)
self._jupyterhub_user = requester
self._resolve_scopes()
user = self.find_user(user_name) user = self.find_user(user_name)
if requester is not user and not requester.admin: kind = 'user' if isinstance(requester, User) else 'service'
raise web.HTTPError(403, "Only admins can request tokens for other users") scope_filter = self.get_scope_filter('users:tokens')
if not user: if user is None or not scope_filter(user, kind):
raise web.HTTPError(404, "No such user: %s" % user_name) raise web.HTTPError(
if requester is not user: 403,
kind = 'user' if isinstance(requester, User) else 'service' f"{kind.title()} {user_name} not found or no permissions to generate tokens",
)
note = body.get('note') note = body.get('note')
if not note: if not note:
@@ -354,7 +356,7 @@ class UserTokenListAPIHandler(APIHandler):
except ValueError: except ValueError:
raise web.HTTPError( raise web.HTTPError(
403, 403,
"Requested token roles %r have higher permissions than the token owner" "Requested roles %r cannot have higher permissions than the token owner"
% token_roles, % token_roles,
) )
if requester is not user: if requester is not user:

View File

@@ -111,7 +111,6 @@ from .objects import Hub, Server
# For faking stats # For faking stats
from .emptyclass import EmptyClass from .emptyclass import EmptyClass
common_aliases = { common_aliases = {
'log-level': 'Application.log_level', 'log-level': 'Application.log_level',
'f': 'JupyterHub.config_file', 'f': 'JupyterHub.config_file',
@@ -119,7 +118,6 @@ common_aliases = {
'db': 'JupyterHub.db_url', 'db': 'JupyterHub.db_url',
} }
aliases = { aliases = {
'base-url': 'JupyterHub.base_url', 'base-url': 'JupyterHub.base_url',
'y': 'JupyterHub.answer_yes', 'y': 'JupyterHub.answer_yes',
@@ -885,6 +883,66 @@ class JupyterHub(Application):
def _hub_prefix_default(self): def _hub_prefix_default(self):
return url_path_join(self.base_url, '/hub/') return url_path_join(self.base_url, '/hub/')
hub_routespec = Unicode(
"/",
help="""
The routing prefix for the Hub itself.
Override to send only a subset of traffic to the Hub.
Default is to use the Hub as the default route for all requests.
This is necessary for normal jupyterhub operation,
as the Hub must receive requests for e.g. `/user/:name`
when the user's server is not running.
However, some deployments using only the JupyterHub API
may want to handle these events themselves,
in which case they can register their own default target with the proxy
and set e.g. `hub_routespec = /hub/` to serve only the hub's own pages, or even `/hub/api/` for api-only operation.
Note: hub_routespec must include the base_url, if any.
.. versionadded:: 1.4
""",
).tag(config=True)
@default("hub_routespec")
def _default_hub_routespec(self):
# Default routespec for the Hub is the *app* base url
# not the hub URL, so the Hub receives requests for non-running servers
# use `/` with host-based routing so the Hub
# gets requests for all hosts
if self.subdomain_host:
routespec = '/'
else:
routespec = self.base_url
return routespec
@validate("hub_routespec")
def _validate_hub_routespec(self, proposal):
"""ensure leading/trailing / on custom routespec prefix
- trailing '/' always required
- leading '/' required unless using subdomains
"""
routespec = proposal.value
if not routespec.endswith("/"):
routespec = routespec + "/"
if not self.subdomain_host and not routespec.startswith("/"):
routespec = "/" + routespec
return routespec
@observe("hub_routespec")
def _hub_routespec_changed(self, change):
if change.new == change.old:
return
routespec = change.new
if routespec not in {'/', self.base_url}:
self.log.warning(
f"Using custom route for Hub: {routespec}."
" Requests for not-running servers may not be handled."
)
@observe('base_url') @observe('base_url')
def _update_hub_prefix(self, change): def _update_hub_prefix(self, change):
"""add base URL to hub prefix""" """add base URL to hub prefix"""
@@ -1718,6 +1776,7 @@ class JupyterHub(Application):
"""Load the Hub URL config""" """Load the Hub URL config"""
hub_args = dict( hub_args = dict(
base_url=self.hub_prefix, base_url=self.hub_prefix,
routespec=self.hub_routespec,
public_host=self.subdomain_host, public_host=self.subdomain_host,
certfile=self.internal_ssl_cert, certfile=self.internal_ssl_cert,
keyfile=self.internal_ssl_key, keyfile=self.internal_ssl_key,
@@ -1733,17 +1792,15 @@ class JupyterHub(Application):
hub_args['ip'] = self.hub_ip hub_args['ip'] = self.hub_ip
hub_args['port'] = self.hub_port hub_args['port'] = self.hub_port
# routespec for the Hub is the *app* base url self.hub = Hub(**hub_args)
# not the hub URL, so it receives requests for non-running servers
# use `/` with host-based routing so the Hub
# gets requests for all hosts
host = ''
if self.subdomain_host:
routespec = '/'
else:
routespec = self.base_url
self.hub = Hub(routespec=routespec, **hub_args) if not self.subdomain_host:
api_prefix = url_path_join(self.hub.base_url, "api/")
if not api_prefix.startswith(self.hub.routespec):
self.log.warning(
f"Hub API prefix {api_prefix} not on prefix {self.hub.routespec}. "
"The Hub may not receive any API requests from outside."
)
if self.hub_connect_ip: if self.hub_connect_ip:
self.hub.connect_ip = self.hub_connect_ip self.hub.connect_ip = self.hub_connect_ip
@@ -2134,6 +2191,7 @@ class JupyterHub(Application):
client_id=service.oauth_client_id, client_id=service.oauth_client_id,
client_secret=service.api_token, client_secret=service.api_token,
redirect_uri=service.oauth_redirect_uri, redirect_uri=service.oauth_redirect_uri,
allowed_roles=service.oauth_roles,
description="JupyterHub service %s" % service.name, description="JupyterHub service %s" % service.name,
) )

View File

@@ -26,10 +26,9 @@ def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sq
Parameters Parameters
---------- ----------
alembic_ini : str
alembic_ini: str
path to the alembic.ini file that should be written. path to the alembic.ini file that should be written.
db_url: str db_url : str
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
""" """
with open(ALEMBIC_INI_TEMPLATE_PATH) as f: with open(ALEMBIC_INI_TEMPLATE_PATH) as f:
@@ -58,13 +57,11 @@ def _temp_alembic_ini(db_url):
Parameters Parameters
---------- ----------
db_url : str
db_url: str
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
Returns Returns
------- -------
alembic_ini: str alembic_ini: str
The path to the temporary alembic.ini that we have created. The path to the temporary alembic.ini that we have created.
This file will be cleaned up on exit from the context manager. This file will be cleaned up on exit from the context manager.

View File

@@ -1,3 +1,4 @@
"""Handlers for serving prometheus metrics"""
from prometheus_client import CONTENT_TYPE_LATEST from prometheus_client import CONTENT_TYPE_LATEST
from prometheus_client import generate_latest from prometheus_client import generate_latest
from prometheus_client import REGISTRY from prometheus_client import REGISTRY
@@ -17,4 +18,7 @@ class MetricsHandler(BaseHandler):
self.write(generate_latest(REGISTRY)) self.write(generate_latest(REGISTRY))
default_handlers = [(r'/metrics$', MetricsHandler)] default_handlers = [
(r'/metrics$', MetricsHandler),
(r'/api/metrics$', MetricsHandler),
]

View File

@@ -676,4 +676,5 @@ default_handlers = [
(r'/token', TokenPageHandler), (r'/token', TokenPageHandler),
(r'/error/(\d+)', ProxyErrorHandler), (r'/error/(\d+)', ProxyErrorHandler),
(r'/health$', HealthCheckHandler), (r'/health$', HealthCheckHandler),
(r'/api/health$', HealthCheckHandler),
] ]

View File

@@ -586,7 +586,9 @@ class JupyterHubOAuthServer(WebApplicationServer):
self.db = db self.db = db
super().__init__(validator, *args, **kwargs) 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 """Add a client
hash its client_secret before putting it in the database. hash its client_secret before putting it in the database.
@@ -607,9 +609,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
app_log.info(f'Creating oauth client {client_id}') app_log.info(f'Creating oauth client {client_id}')
else: else:
app_log.info(f'Updating oauth client {client_id}') app_log.info(f'Updating oauth client {client_id}')
if allowed_roles == None:
allowed_roles = []
orm_client.secret = hash_token(client_secret) if client_secret else "" orm_client.secret = hash_token(client_secret) if client_secret else ""
orm_client.redirect_uri = redirect_uri orm_client.redirect_uri = redirect_uri
orm_client.description = description orm_client.description = description
orm_client.allowed_roles = allowed_roles
self.db.commit() self.db.commit()
def fetch_by_client_id(self, client_id): def fetch_by_client_id(self, client_id):

View File

@@ -32,6 +32,7 @@ from tornado.ioloop import PeriodicCallback
from traitlets import Any from traitlets import Any
from traitlets import Bool from traitlets import Bool
from traitlets import default from traitlets import default
from traitlets import Dict
from traitlets import Instance from traitlets import Instance
from traitlets import Integer from traitlets import Integer
from traitlets import observe from traitlets import observe
@@ -112,6 +113,26 @@ class Proxy(LoggingConfigurable):
""", """,
) )
extra_routes = Dict(
{},
config=True,
help="""
Additional routes to be maintained in the proxy.
A dictionary with a route specification as key, and
a URL as target. The hub will ensure this route is present
in the proxy.
If the hub is running in host based mode (with
JupyterHub.subdomain_host set), the routespec *must*
have a domain component (example.com/my-url/). If the
hub is not running in host based mode, the routespec
*must not* have a domain component (/my-url/).
Helpful when the hub is running in API-only mode.
""",
)
def start(self): def start(self):
"""Start the proxy. """Start the proxy.
@@ -330,7 +351,7 @@ class Proxy(LoggingConfigurable):
route = routes[self.app.hub.routespec] route = routes[self.app.hub.routespec]
if route['target'] != hub.host: if route['target'] != hub.host:
self.log.warning( self.log.warning(
"Updating default route %s%s", route['target'], hub.host "Updating Hub route %s%s", route['target'], hub.host
) )
futures.append(self.add_hub_route(hub)) futures.append(self.add_hub_route(hub))
@@ -384,6 +405,11 @@ class Proxy(LoggingConfigurable):
) )
futures.append(self.add_service(service)) futures.append(self.add_service(service))
# Add extra routes we've been configured for
for routespec, url in self.extra_routes.items():
good_routes.add(routespec)
futures.append(self.add_route(routespec, url, {'extra': True}))
# Now delete the routes that shouldn't be there # Now delete the routes that shouldn't be there
for routespec in routes: for routespec in routes:
if routespec not in good_routes: if routespec not in good_routes:
@@ -396,7 +422,7 @@ class Proxy(LoggingConfigurable):
def add_hub_route(self, hub): def add_hub_route(self, hub):
"""Add the default route for the Hub""" """Add the default route for the Hub"""
self.log.info("Adding default route for Hub: %s => %s", hub.routespec, hub.host) self.log.info("Adding route for Hub: %s => %s", hub.routespec, hub.host)
return self.add_route(hub.routespec, self.hub.host, {'hub': True}) return self.add_route(hub.routespec, self.hub.host, {'hub': True})
async def restore_routes(self): async def restore_routes(self):

View File

@@ -668,12 +668,15 @@ class HubOAuth(HubAuth):
Parameters Parameters
---------- ----------
handler (RequestHandler): A tornado RequestHandler handler : RequestHandler
next_url (str): The page to redirect to on successful login A tornado RequestHandler
next_url : str
The page to redirect to on successful login
Returns Returns
------- -------
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded) state : str
The OAuth state that has been stored in the cookie (url safe, base64-encoded)
""" """
extra_state = {} extra_state = {}
if handler.get_cookie(self.state_cookie_name): if handler.get_cookie(self.state_cookie_name):
@@ -710,7 +713,8 @@ class HubOAuth(HubAuth):
Parameters Parameters
---------- ----------
next_url (str): The URL of the page to redirect to on successful login. next_url : str
The URL of the page to redirect to on successful login.
Returns Returns
------- -------

View File

@@ -50,6 +50,7 @@ from traitlets import default
from traitlets import Dict from traitlets import Dict
from traitlets import HasTraits from traitlets import HasTraits
from traitlets import Instance from traitlets import Instance
from traitlets import List
from traitlets import Unicode from traitlets import Unicode
from traitlets import validate from traitlets import validate
from traitlets.config import LoggingConfigurable from traitlets.config import LoggingConfigurable
@@ -189,6 +190,19 @@ class Service(LoggingConfigurable):
""" """
).tag(input=True) ).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( api_token = Unicode(
help="""The API token to use for the service. help="""The API token to use for the service.

View File

@@ -219,6 +219,20 @@ class Spawner(LoggingConfigurable):
oauth_client_id = Unicode() oauth_client_id = Unicode()
handler = Any() 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( will_resume = Bool(
False, False,
help="""Whether the Spawner will resume on next start help="""Whether the Spawner will resume on next start
@@ -729,7 +743,7 @@ class Spawner(LoggingConfigurable):
Returns Returns
------- -------
state: dict state: dict
a JSONable dict of state a JSONable dict of state
""" """
state = {} state = {}
return state return state

View File

@@ -1310,7 +1310,7 @@ async def test_get_new_token(app, headers, status, note, expires_in):
"as_user, for_user, status", "as_user, for_user, status",
[ [
('admin', 'other', 200), ('admin', 'other', 200),
('admin', 'missing', 404), ('admin', 'missing', 403),
('user', 'other', 403), ('user', 'other', 403),
('user', 'user', 200), ('user', 'user', 200),
], ],

View File

@@ -1,5 +1,6 @@
"""Test the JupyterHub entry point""" """Test the JupyterHub entry point"""
import binascii import binascii
import logging
import os import os
import re import re
import sys import sys
@@ -329,3 +330,41 @@ def test_url_config(hub_config, expected):
# validate additional properties # validate additional properties
for key, value in expected.items(): for key, value in expected.items():
assert getattr(app, key) == value assert getattr(app, key) == value
@pytest.mark.parametrize(
"base_url, hub_routespec, expected_routespec, should_warn, bad_prefix",
[
(None, None, "/", False, False),
("/", "/", "/", False, False),
("/base", "/base", "/base/", False, False),
("/", "/hub", "/hub/", True, False),
(None, "hub/api", "/hub/api/", True, False),
("/base", "/hub/", "/hub/", True, True),
(None, "/hub/api/health", "/hub/api/health/", True, True),
],
)
def test_hub_routespec(
base_url, hub_routespec, expected_routespec, should_warn, bad_prefix, caplog
):
cfg = Config()
if base_url:
cfg.JupyterHub.base_url = base_url
if hub_routespec:
cfg.JupyterHub.hub_routespec = hub_routespec
with caplog.at_level(logging.WARNING):
app = JupyterHub(config=cfg, log=logging.getLogger())
app.init_hub()
hub = app.hub
assert hub.routespec == expected_routespec
if should_warn:
assert "custom route for Hub" in caplog.text
assert hub_routespec in caplog.text
else:
assert "custom route for Hub" not in caplog.text
if bad_prefix:
assert "may not receive" in caplog.text
else:
assert "may not receive" not in caplog.text

View File

@@ -195,6 +195,25 @@ async def test_check_routes(app, username, disable_check_routes):
assert before == after assert before == after
async def test_extra_routes(app):
proxy = app.proxy
# When using host_routing, it's up to the admin to
# provide routespecs that have a domain in them.
# We don't explicitly validate that here.
if app.subdomain_host:
route_spec = 'example.com/test-extra-routes/'
else:
route_spec = '/test-extra-routes/'
target = 'http://localhost:9999/test'
proxy.extra_routes = {route_spec: target}
await proxy.check_routes(app.users, app._service_map)
routes = await app.proxy.get_all_routes()
assert route_spec in routes
assert routes[route_spec]['target'] == target
@pytest.mark.parametrize( @pytest.mark.parametrize(
"routespec", "routespec",
[ [

View File

@@ -14,6 +14,7 @@ from ..scopes import get_scopes_for
from ..utils import maybe_future from ..utils import maybe_future
from ..utils import utcnow from ..utils import utcnow
from .mocking import MockHub from .mocking import MockHub
from .test_scopes import create_temp_role
from .utils import add_user from .utils import add_user
from .utils import api_request from .utils import api_request
@@ -1016,3 +1017,19 @@ async def test_server_role_api_calls(
assert ( assert (
all(key for key in ['groups', 'roles', 'servers']) not in user_model.keys() all(key for key in ['groups', 'roles', 'servers']) not in user_model.keys()
) )
async def test_oauth_allowed_roles(app, create_temp_role):
allowed_roles = ['oracle', 'goose']
service = {
'name': 'oas1',
'api_token': 'some-token',
'oauth_roles': ['oracle', 'goose'],
}
for role in allowed_roles:
create_temp_role('read:users', role_name=role)
app.services.append(service)
app.init_services()
app_service = app.services[0]
assert app_service['name'] == 'oas1'
assert set(app_service['oauth_roles']) == set(allowed_roles)

View File

@@ -426,3 +426,9 @@ async def test_hub_connect_url(db):
env["JUPYTERHUB_ACTIVITY_URL"] env["JUPYTERHUB_ACTIVITY_URL"]
== "https://example.com/api/users/%s/activity" % name == "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

@@ -72,9 +72,8 @@ def check_db_locks(func):
The decorator relies on an instance of JupyterHubApp being the first The decorator relies on an instance of JupyterHubApp being the first
argument to the decorated function. argument to the decorated function.
Example Examples
------- --------
@check_db_locks @check_db_locks
def api_request(app, *api_path, **kwargs): def api_request(app, *api_path, **kwargs):

View File

@@ -564,10 +564,16 @@ class User:
oauth_client = oauth_provider.fetch_by_client_id(client_id) oauth_client = oauth_provider.fetch_by_client_id(client_id)
# create a new OAuth client + secret on every launch # create a new OAuth client + secret on every launch
# containers that resume will be updated below # containers that resume will be updated below
allowed_roles = spawner.oauth_roles
if callable(allowed_roles):
allowed_roles = allowed_roles(spawner)
oauth_provider.add_client( oauth_provider.add_client(
client_id, client_id,
api_token, api_token,
url_path_join(self.url, server_name, 'oauth_callback'), url_path_join(self.url, server_name, 'oauth_callback'),
allowed_roles=allowed_roles,
description="Server at %s" description="Server at %s"
% (url_path_join(self.base_url, server_name) + '/'), % (url_path_join(self.base_url, server_name) + '/'),
) )

View File

@@ -2,7 +2,7 @@
If you base a Dockerfile on this image: If you base a Dockerfile on this image:
FROM jupyterhub/jupyterhub-onbuild:0.6 FROM jupyterhub/jupyterhub-onbuild:1.4.0
... ...
then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub. then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub.

View File

@@ -10,6 +10,7 @@
{% block login %} {% block login %}
<div id="login-main" class="container"> <div id="login-main" class="container">
{% block login_container %}
{% if custom_html %} {% if custom_html %}
{{ custom_html | safe }} {{ custom_html | safe }}
{% elif login_service %} {% elif login_service %}
@@ -83,6 +84,7 @@
</div> </div>
</form> </form>
{% endif %} {% endif %}
{% endblock login_container %}
</div> </div>
{% endblock login %} {% endblock login %}