mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-12 04:23:01 +00:00
Merge branch 'rbac' into rbac-fix-server-scope
This commit is contained in:
@@ -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
|
117
.github/workflows/release.yml
vendored
117
.github/workflows/release.yml
vendored
@@ -66,3 +66,120 @@ jobs:
|
||||
run: |
|
||||
pip install twine
|
||||
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)) }}
|
||||
|
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
@@ -222,3 +222,25 @@ jobs:
|
||||
- name: Submit codecov report
|
||||
run: |
|
||||
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
|
||||
|
@@ -21,7 +21,7 @@
|
||||
# your jupyterhub_config.py will be added automatically
|
||||
# from your docker directory.
|
||||
|
||||
ARG BASE_IMAGE=ubuntu:focal-20200729@sha256:6f2fb2f9fb5582f8b587837afd6ea8f37d8d1d9e41168c90f410a6ef15fa8ce5
|
||||
ARG BASE_IMAGE=ubuntu:focal-20200729
|
||||
FROM $BASE_IMAGE AS builder
|
||||
|
||||
USER root
|
||||
|
@@ -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/75885ee24636efbfebfceed1043459715049cd84.zip
|
||||
myst-parser
|
||||
pydata-sphinx-theme
|
||||
pytablewriter>=0.56
|
||||
recommonmark>=0.6
|
||||
sphinx>=1.7
|
||||
sphinx-copybutton
|
||||
sphinx-jsonschema
|
||||
|
@@ -3,7 +3,7 @@ swagger: "2.0"
|
||||
info:
|
||||
title: JupyterHub
|
||||
description: The REST API for JupyterHub
|
||||
version: 1.2.0dev
|
||||
version: 1.4.0
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
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?
|
||||
tokenUrl: "/hub/api/oauth2/token"
|
||||
scopes:
|
||||
all: Everything a user can do
|
||||
read:all: Read-only access to everything a user can read (also whoami handler)
|
||||
users: Grants access to managing users including reading users’ model, posting activity and starting/stoping users servers
|
||||
read:users: Read-only access to the above
|
||||
read:users!user=username: Read-only access to a single user's model
|
||||
read:users:names: Read-only access to users' names
|
||||
read:users:groups: Read-only access to users' groups
|
||||
read:users:activity: Read-only access to users' activity
|
||||
read:users:activity!group=groupname: Read-only access to specific group's users' activity
|
||||
read:users:servers: Read-only access to users' servers
|
||||
users:activity!user=username: Update a user's activity
|
||||
users:servers: Grants access to start/stop any server
|
||||
users:servers!server=servername: Limits the above to a specific server
|
||||
users:tokens: Grants access to users' token (includes create/revoke a token)
|
||||
read:users:tokens: Identify a user from a token
|
||||
admin:users: Grants access to creating/removing users
|
||||
admin:users:servers: Grants access to create/remove users' servers
|
||||
groups: Add/remove users from any group
|
||||
groups!group=groupname: Add/remove users from a specific group only
|
||||
read:groups: Read-only access to groups
|
||||
admin:groups: Grants access to create/delete groups
|
||||
read:services: Read-only access to services
|
||||
read:hub: Read-only access to detailed information about JupyterHub
|
||||
proxy: Grants access to proxy's routing table, syncing and notifying about a new proxy
|
||||
shutdown: Grants access to shutdown the Hub
|
||||
security: # global security, do we want to keep only the apiKey (token: []), change to only oauth2 (with scope all) or have both (either can be used)?
|
||||
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.
|
||||
admin:users:auth_state: Grants access to users' authentication state only.
|
||||
users: Grants read and write permissions to users' models apart from servers, tokens and authentication state.
|
||||
users:activity: Grants access to read and post users' activity only.
|
||||
users:activity!user=username: Update a single user's activity (example horizontal filter).
|
||||
read:users: Read-only access to users' models apart from servers, tokens and authentication state.
|
||||
read:users!user=username: As above limited to a specific user (example horizontal filter).
|
||||
read:users:name: Read-only access to users' names.
|
||||
read:users:roles: Read-only access to a list of users' roles names.
|
||||
read:users:groups: Read-only access to a list of users' group names.
|
||||
read:users:activity: Read-only access to users' activity.
|
||||
read:users:activity!group=groupname: Read-only access to specific group's users' activity (example horizontal filter).
|
||||
admin:users:servers: Grants read, start/stop, create and delete permissions to users' servers and their state.
|
||||
admin:users:server_state: Grants access to servers' state only.
|
||||
users:servers: Allows for starting/stopping users' servers in addition to read access to their models. Does not include the server state.
|
||||
users:servers!server=servername: Limits the above to a specific server (example horizontal filter).
|
||||
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.
|
||||
read:users:tokens: Read-only access to users' tokens.
|
||||
admin:groups: Grants read, write, create and delete access to groups.
|
||||
groups: Grants read and write permissions to groups, including adding/removing users to/from groups.
|
||||
groups!group=groupname: As above limited to a specific group only (example horizontal filter)
|
||||
read:groups: Read-only access to groups.
|
||||
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: []
|
||||
- oauth2:
|
||||
- all
|
||||
- self
|
||||
basePath: /hub/api
|
||||
produces:
|
||||
- application/json
|
||||
@@ -116,8 +122,12 @@ paths:
|
||||
summary: List users
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- read:users
|
||||
- read:users:name
|
||||
- read:users:groups
|
||||
- read:users:activity
|
||||
- read:users:servers
|
||||
#TODO: add admin:users:auth_state/server_state?
|
||||
parameters:
|
||||
- name: state
|
||||
in: query
|
||||
@@ -173,9 +183,13 @@ paths:
|
||||
summary: Get a user by name
|
||||
security:
|
||||
- oauth2:
|
||||
- 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:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -208,7 +222,7 @@ paths:
|
||||
description: Change a user's name or admin status
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- admin:users
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -255,8 +269,7 @@ paths:
|
||||
actively using a server.
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- users:activity!user=username
|
||||
- users:activity
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -311,7 +324,6 @@ paths:
|
||||
summary: Start a user's single-user notebook server
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- users:servers
|
||||
parameters:
|
||||
- name: name
|
||||
@@ -341,7 +353,6 @@ paths:
|
||||
summary: Stop a user's server
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- users:servers
|
||||
parameters:
|
||||
- name: name
|
||||
@@ -359,9 +370,7 @@ paths:
|
||||
summary: Start a user's single-user named-server notebook server
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- users:servers
|
||||
- users:servers!server=servername
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -395,9 +404,7 @@ paths:
|
||||
summary: Stop a user's named-server
|
||||
security:
|
||||
- oauth2:
|
||||
- users
|
||||
- users:servers
|
||||
- users:servers!server=servername
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
@@ -437,7 +444,7 @@ paths:
|
||||
summary: List tokens for the user
|
||||
security:
|
||||
- oauth2:
|
||||
- users:tokens
|
||||
- read:users:tokens
|
||||
responses:
|
||||
"200":
|
||||
description: The list of tokens
|
||||
@@ -468,7 +475,9 @@ paths:
|
||||
type: string
|
||||
description: A note attached to the token for future bookkeeping
|
||||
roles:
|
||||
type: list
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: A list of role names that the token should have
|
||||
responses:
|
||||
"201":
|
||||
@@ -494,7 +503,7 @@ paths:
|
||||
summary: Get the model for a token by id
|
||||
security:
|
||||
- oauth2:
|
||||
- users:tokens
|
||||
- read:users:tokens
|
||||
responses:
|
||||
"200":
|
||||
description: The info for the new token
|
||||
@@ -513,8 +522,13 @@ paths:
|
||||
summary: Return authenticated user's model
|
||||
security:
|
||||
- oauth2:
|
||||
- all
|
||||
- read:all
|
||||
- read:users
|
||||
- read:users:name
|
||||
- read:users:groups
|
||||
- read:users:activity
|
||||
- read:users:servers
|
||||
- admin:users:auth_state
|
||||
- admin:users:server_state
|
||||
responses:
|
||||
"200":
|
||||
description: The authenticated user's model is returned.
|
||||
@@ -525,7 +539,6 @@ paths:
|
||||
summary: List groups
|
||||
security:
|
||||
- oauth2:
|
||||
- groups
|
||||
- read:groups
|
||||
responses:
|
||||
"200":
|
||||
@@ -539,8 +552,6 @@ paths:
|
||||
summary: Get a group by name
|
||||
security:
|
||||
- oauth2:
|
||||
- groups
|
||||
- groups!group=groupname
|
||||
- read:groups
|
||||
parameters:
|
||||
- name: name
|
||||
@@ -589,7 +600,6 @@ paths:
|
||||
security:
|
||||
- oauth2:
|
||||
- groups
|
||||
- groups!group=groupname
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -618,7 +628,6 @@ paths:
|
||||
security:
|
||||
- oauth2:
|
||||
- groups
|
||||
- groups!group=groupname
|
||||
parameters:
|
||||
- name: name
|
||||
description: group name
|
||||
@@ -933,6 +942,10 @@ 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:
|
||||
@@ -969,7 +982,7 @@ 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.
|
||||
|
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Frequently asked questions
|
||||
|
||||
### How do I share links to notebooks?
|
||||
## How do I share links to notebooks?
|
||||
|
||||
In short, where you see `/user/name/notebooks/foo.ipynb` use `/hub/user-redirect/notebooks/foo.ipynb` (replace `/user/name` with `/hub/user-redirect`).
|
||||
|
||||
|
BIN
docs/source/images/rbac-api-request-chart.png
Normal file
BIN
docs/source/images/rbac-api-request-chart.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 446 KiB |
BIN
docs/source/images/rbac-token-request-chart.png
Normal file
BIN
docs/source/images/rbac-token-request-chart.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 483 KiB |
@@ -108,6 +108,14 @@ API Reference
|
||||
|
||||
api/index
|
||||
|
||||
RBAC Reference
|
||||
--------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
rbac/index
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
|
37
docs/source/rbac/index.md
Normal file
37
docs/source/rbac/index.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# JupyterHub RBAC
|
||||
|
||||
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
|
||||
|
||||
RBAC is new in JupyterHub 2.0.
|
||||
|
||||
## Motivation
|
||||
|
||||
The JupyterHub API requires authorization to access its APIs.
|
||||
This ensures that an arbitrary user, or even an unauthenticated third party, are not allowed to perform such actions.
|
||||
For instance, the behaviour prior to adoption of RBAC is that creating or deleting users requires _admin rights_.
|
||||
|
||||
The prior system is functional, but lacks flexibility. If your Hub serves a number of users in different groups, you might want to delegate permissions to other users or automate certain processes.
|
||||
Prior to RBAC, appointing a 'group-only admin' or a bot that culls idle servers, requires granting full admin rights to all actions. This poses a risk of the user or service intentionally or unintentionally accessing and modifying any data within the Hub and violates the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege).
|
||||
|
||||
To remedy situations like this, JupyterHub is transitioning to an RBAC system. By equipping users, groups and services with _roles_ that supply them with a collection of permissions (_scopes_), administrators are able to fine-tune which parties are granted access to which resources.
|
||||
|
||||
## Definitions
|
||||
|
||||
**Scopes** are specific permissions used to evaluate API requests. For example: the API endpoint `users/servers`, which enables starting or stopping user servers, is guarded by the scope `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
111
docs/source/rbac/roles.md
Normal 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 token’s permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role.
|
||||
|
||||
(define_role_target)=
|
||||
|
||||
## Defining Roles
|
||||
|
||||
Roles can be defined or modified in the configuration file as a list of dictionaries. An example:
|
||||
|
||||
% TODO: think about loading users/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 user’s model.
|
||||
|
||||
```{admonition} Requirements
|
||||
:class: warning
|
||||
In a role definition, the `name` field is required, while all other fields are optional.\
|
||||
**Role names must:**
|
||||
- be 3 - 255 characters
|
||||
- use ascii lowercase, numbers, 'unreserved' URL punctuation `-_.~`
|
||||
- start with a letter
|
||||
- end with letter or number.
|
||||
|
||||
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
125
docs/source/rbac/scopes.md
Normal 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._ |
|
||||
| `admin:users:auth_state` | Grants access to users' authentication state only. |
|
||||
| `users` | Grants read and write permissions to users' models _apart from servers, tokens and authentication state_. |
|
||||
| `users:activity` | Grants access to read and post users' activity only. |
|
||||
| `read:users` | Read-only access to users' models _apart from servers, tokens and authentication state_. |
|
||||
| `read:users:name` | Read-only access to users' names. |
|
||||
| `read:users:roles` | Read-only access to a list of users' roles names. |
|
||||
| `read:users:groups` | Read-only access to a list of users' group names. |
|
||||
| `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. |
|
||||
| `admin:users:server_state` | Grants access to servers' state only. |
|
||||
| `users:servers` | Allows for starting/stopping users' servers in addition to read access to their models. _Does not include the server state_. |
|
||||
| `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. |
|
||||
| `read:users:tokens` | Read-only access to users' tokens. |
|
||||
| `admin:groups` | Grants read, write, create and delete access to groups. |
|
||||
| `groups` | Grants read and write permissions to groups, including adding/removing users to/from groups. |
|
||||
| `read:groups` | Read-only access to groups. |
|
||||
| `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 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.
|
71
docs/source/rbac/tech-implementation.md
Normal file
71
docs/source/rbac/tech-implementation.md
Normal 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
|
||||
```
|
54
docs/source/rbac/upgrade.md
Normal file
54
docs/source/rbac/upgrade.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Upgrading JupyterHub with RBAC framework
|
||||
|
||||
RBAC framework requires different database setup than any previous JupyterHub versions due to eliminating the distinction between OAuth and API tokens (see {ref}`oauth-vs-api-tokens-target` for more details). This requires merging the previously two different database tables into one. By doing so, all existing tokens created before the upgrade no longer comply with the new database version and must be replaced.
|
||||
|
||||
This is achieved by the Hub deleting all existing tokens during the database upgrade and recreating the tokens loaded via the `jupyterhub_config.py` file with updated structure. However, any manually issued or stored tokens are not recreated automatically and must be manually re-issued after the upgrade.
|
||||
|
||||
No other database records are affected.
|
||||
|
||||
(rbac-upgrade-steps-target)=
|
||||
|
||||
## Upgrade steps
|
||||
|
||||
1. All running **servers must be stopped** before proceeding with the upgrade.
|
||||
2. To upgrade the Hub, follow the [Upgrading JupyterHub](../admin/upgrading.rst) instructions.
|
||||
```{attention}
|
||||
We advise against defining any new roles in the `jupyterhub.config.py` file right after the upgrade is completed and JupyterHub restarted for the first time. This preserves the 'current' state of the Hub. You can define and assign new roles on any other following startup.
|
||||
```
|
||||
3. After restarting the Hub **re-issue all tokens that were previously issued manually** (i.e., not through the `jupyterhub_config.py` file).
|
||||
|
||||
When the JupyterHub is restarted for the first time after the upgrade, all users, services and tokens stored in the database or re-loaded through the configuration file will be assigned their default role. Any newly added entities after that will be assigned their default role only if no other specific role is requested for them.
|
||||
|
||||
## Changing the permissions after the upgrade
|
||||
|
||||
Once all the {ref}`upgrade steps <rbac-upgrade-steps-target>` above are completed, the RBAC framework will be available for utilization. You can define new roles, modify default roles (apart from `admin`) and assign them to entities as described in the {ref}`define_role_target` section.
|
||||
|
||||
We recommended the following procedure to start with RBAC:
|
||||
|
||||
1. Identify which admin users and services you would like to grant only the permissions they need through the new roles
|
||||
2. Strip these users and services of their admin status via API or UI. This will change their roles from `admin` to `user`.
|
||||
```{note}
|
||||
Stripping entities of their roles is currently 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.
|
130
docs/source/rbac/use-cases.md
Normal file
130
docs/source/rbac/use-cases.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Use Cases
|
||||
|
||||
To determine which scopes a role should have, one can follow these steps:
|
||||
|
||||
1. Determine what actions the role holder should have/have not access to
|
||||
2. Match the actions against the [JupyterHub's APIs](../reference/rest-api.rst)
|
||||
3. Check which scopes are required to access the APIs
|
||||
4. Combine scopes and subscopes if applicable
|
||||
5. Customize the scopes with filters if needed
|
||||
6. Define the role with required scopes and assign to users/services/groups/tokens
|
||||
|
||||
Below, different use cases are presented on how to use the RBAC framework.
|
||||
|
||||
## Service to cull idle servers
|
||||
|
||||
Finding and shutting down idle servers can save a lot of computational resources.
|
||||
We can make use of [jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler) to manage this for us.
|
||||
Below follows a short tutorial on how to add a cull-idle service in the RBAC system.
|
||||
|
||||
1. Install the cull-idle server script with `pip install jupyterhub-idle-culler`.
|
||||
2. Define a new service `idle-culler` and a new role for this service:
|
||||
|
||||
```python
|
||||
# in jupyterhub_config.py
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
"name": "idle-culler",
|
||||
"command": [
|
||||
sys.executable, "-m",
|
||||
"jupyterhub_idle_culler",
|
||||
"--timeout=3600"
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "idle-culler",
|
||||
"description": "Culls idle servers",
|
||||
"scopes": ["read:users:name", "read:users:activity", "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.
|
||||
```
|
@@ -1,3 +0,0 @@
|
||||
# Docker Cloud build hooks
|
||||
|
||||
These are the hooks
|
@@ -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
|
@@ -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
|
@@ -301,7 +301,6 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
|
||||
self.write(json.dumps({'api_tokens': api_tokens}))
|
||||
|
||||
# @needs_scope('users:tokens') #Todo: needs internal scope checking
|
||||
async def post(self, user_name):
|
||||
body = self.get_json_body() or {}
|
||||
if not isinstance(body, dict):
|
||||
@@ -330,13 +329,16 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
if requester is None:
|
||||
# couldn't identify requester
|
||||
raise web.HTTPError(403)
|
||||
self._jupyterhub_user = requester
|
||||
self._resolve_scopes()
|
||||
user = self.find_user(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" % user_name)
|
||||
if requester is not user:
|
||||
kind = 'user' if isinstance(requester, User) else 'service'
|
||||
kind = 'user' if isinstance(requester, User) else 'service'
|
||||
scope_filter = self.get_scope_filter('users: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:
|
||||
@@ -354,7 +356,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
except ValueError:
|
||||
raise web.HTTPError(
|
||||
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,
|
||||
)
|
||||
if requester is not user:
|
||||
|
@@ -111,7 +111,6 @@ from .objects import Hub, Server
|
||||
# For faking stats
|
||||
from .emptyclass import EmptyClass
|
||||
|
||||
|
||||
common_aliases = {
|
||||
'log-level': 'Application.log_level',
|
||||
'f': 'JupyterHub.config_file',
|
||||
@@ -119,7 +118,6 @@ common_aliases = {
|
||||
'db': 'JupyterHub.db_url',
|
||||
}
|
||||
|
||||
|
||||
aliases = {
|
||||
'base-url': 'JupyterHub.base_url',
|
||||
'y': 'JupyterHub.answer_yes',
|
||||
@@ -885,6 +883,66 @@ class JupyterHub(Application):
|
||||
def _hub_prefix_default(self):
|
||||
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')
|
||||
def _update_hub_prefix(self, change):
|
||||
"""add base URL to hub prefix"""
|
||||
@@ -1718,6 +1776,7 @@ class JupyterHub(Application):
|
||||
"""Load the Hub URL config"""
|
||||
hub_args = dict(
|
||||
base_url=self.hub_prefix,
|
||||
routespec=self.hub_routespec,
|
||||
public_host=self.subdomain_host,
|
||||
certfile=self.internal_ssl_cert,
|
||||
keyfile=self.internal_ssl_key,
|
||||
@@ -1733,17 +1792,15 @@ class JupyterHub(Application):
|
||||
hub_args['ip'] = self.hub_ip
|
||||
hub_args['port'] = self.hub_port
|
||||
|
||||
# routespec for the Hub is the *app* base url
|
||||
# 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(**hub_args)
|
||||
|
||||
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:
|
||||
self.hub.connect_ip = self.hub_connect_ip
|
||||
@@ -2134,6 +2191,7 @@ class JupyterHub(Application):
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
allowed_roles=service.oauth_roles,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
|
||||
|
@@ -26,10 +26,9 @@ def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sq
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
alembic_ini: str
|
||||
alembic_ini : str
|
||||
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`.
|
||||
"""
|
||||
with open(ALEMBIC_INI_TEMPLATE_PATH) as f:
|
||||
@@ -58,13 +57,11 @@ def _temp_alembic_ini(db_url):
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
db_url: str
|
||||
db_url : str
|
||||
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
||||
alembic_ini: str
|
||||
The path to the temporary alembic.ini that we have created.
|
||||
This file will be cleaned up on exit from the context manager.
|
||||
|
@@ -1,3 +1,4 @@
|
||||
"""Handlers for serving prometheus metrics"""
|
||||
from prometheus_client import CONTENT_TYPE_LATEST
|
||||
from prometheus_client import generate_latest
|
||||
from prometheus_client import REGISTRY
|
||||
@@ -17,4 +18,7 @@ class MetricsHandler(BaseHandler):
|
||||
self.write(generate_latest(REGISTRY))
|
||||
|
||||
|
||||
default_handlers = [(r'/metrics$', MetricsHandler)]
|
||||
default_handlers = [
|
||||
(r'/metrics$', MetricsHandler),
|
||||
(r'/api/metrics$', MetricsHandler),
|
||||
]
|
||||
|
@@ -676,4 +676,5 @@ default_handlers = [
|
||||
(r'/token', TokenPageHandler),
|
||||
(r'/error/(\d+)', ProxyErrorHandler),
|
||||
(r'/health$', HealthCheckHandler),
|
||||
(r'/api/health$', HealthCheckHandler),
|
||||
]
|
||||
|
@@ -586,7 +586,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.
|
||||
@@ -607,9 +609,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
app_log.info(f'Creating oauth client {client_id}')
|
||||
else:
|
||||
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.redirect_uri = redirect_uri
|
||||
orm_client.description = description
|
||||
orm_client.allowed_roles = allowed_roles
|
||||
self.db.commit()
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
|
@@ -32,6 +32,7 @@ from tornado.ioloop import PeriodicCallback
|
||||
from traitlets import Any
|
||||
from traitlets import Bool
|
||||
from traitlets import default
|
||||
from traitlets import Dict
|
||||
from traitlets import Instance
|
||||
from traitlets import Integer
|
||||
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):
|
||||
"""Start the proxy.
|
||||
|
||||
@@ -330,7 +351,7 @@ class Proxy(LoggingConfigurable):
|
||||
route = routes[self.app.hub.routespec]
|
||||
if route['target'] != hub.host:
|
||||
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))
|
||||
|
||||
@@ -384,6 +405,11 @@ class Proxy(LoggingConfigurable):
|
||||
)
|
||||
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
|
||||
for routespec in routes:
|
||||
if routespec not in good_routes:
|
||||
@@ -396,7 +422,7 @@ class Proxy(LoggingConfigurable):
|
||||
|
||||
def add_hub_route(self, 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})
|
||||
|
||||
async def restore_routes(self):
|
||||
|
@@ -668,12 +668,15 @@ class HubOAuth(HubAuth):
|
||||
|
||||
Parameters
|
||||
----------
|
||||
handler (RequestHandler): A tornado RequestHandler
|
||||
next_url (str): The page to redirect to on successful login
|
||||
handler : RequestHandler
|
||||
A tornado RequestHandler
|
||||
next_url : str
|
||||
The page to redirect to on successful login
|
||||
|
||||
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 = {}
|
||||
if handler.get_cookie(self.state_cookie_name):
|
||||
@@ -710,7 +713,8 @@ class HubOAuth(HubAuth):
|
||||
|
||||
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
|
||||
-------
|
||||
|
@@ -50,6 +50,7 @@ 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
|
||||
@@ -189,6 +190,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.
|
||||
|
||||
|
@@ -219,6 +219,20 @@ class Spawner(LoggingConfigurable):
|
||||
oauth_client_id = Unicode()
|
||||
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
|
||||
@@ -729,7 +743,7 @@ class Spawner(LoggingConfigurable):
|
||||
Returns
|
||||
-------
|
||||
state: dict
|
||||
a JSONable dict of state
|
||||
a JSONable dict of state
|
||||
"""
|
||||
state = {}
|
||||
return state
|
||||
|
@@ -1310,7 +1310,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),
|
||||
],
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Test the JupyterHub entry point"""
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -329,3 +330,41 @@ def test_url_config(hub_config, expected):
|
||||
# validate additional properties
|
||||
for key, value in expected.items():
|
||||
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
|
||||
|
@@ -195,6 +195,25 @@ async def test_check_routes(app, username, disable_check_routes):
|
||||
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(
|
||||
"routespec",
|
||||
[
|
||||
|
@@ -14,6 +14,7 @@ from ..scopes import get_scopes_for
|
||||
from ..utils import maybe_future
|
||||
from ..utils import utcnow
|
||||
from .mocking import MockHub
|
||||
from .test_scopes import create_temp_role
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
|
||||
@@ -1016,3 +1017,19 @@ async def test_server_role_api_calls(
|
||||
assert (
|
||||
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)
|
||||
|
@@ -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
|
||||
|
@@ -72,9 +72,8 @@ def check_db_locks(func):
|
||||
The decorator relies on an instance of JupyterHubApp being the first
|
||||
argument to the decorated function.
|
||||
|
||||
Example
|
||||
-------
|
||||
|
||||
Examples
|
||||
--------
|
||||
@check_db_locks
|
||||
def api_request(app, *api_path, **kwargs):
|
||||
|
||||
|
@@ -564,10 +564,16 @@ class User:
|
||||
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
|
||||
|
||||
allowed_roles = spawner.oauth_roles
|
||||
if callable(allowed_roles):
|
||||
allowed_roles = allowed_roles(spawner)
|
||||
|
||||
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) + '/'),
|
||||
)
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
|
@@ -10,6 +10,7 @@
|
||||
|
||||
{% block login %}
|
||||
<div id="login-main" class="container">
|
||||
{% block login_container %}
|
||||
{% if custom_html %}
|
||||
{{ custom_html | safe }}
|
||||
{% elif login_service %}
|
||||
@@ -83,6 +84,7 @@
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock login_container %}
|
||||
</div>
|
||||
{% endblock login %}
|
||||
|
||||
|
Reference in New Issue
Block a user