Update service examples and documentation with access scopes and roles

This commit is contained in:
Min RK
2021-05-21 10:17:57 +02:00
parent 69d2839ba3
commit 40de16e0e1
16 changed files with 291 additions and 138 deletions

View File

@@ -86,10 +86,18 @@ Hub-Managed Service would include:
This example would be configured as follows in `jupyterhub_config.py`:
```python
c.JupyterHub.load_roles = [
{
"name": "idle-culler",
"scopes": [
"users:servers",
# also 'admin:users' if culling idle users as well
]
}
c.JupyterHub.services = [
{
'name': 'idle-culler',
'admin': True,
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
}
]
@@ -114,6 +122,7 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
Only for proxied web services.
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
```
For the previous 'cull idle' Service example, these environment variables
@@ -231,50 +240,8 @@ service. See the `service-whoami-flask` example in the
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
for more details.
```python
from functools import wraps
import json
import os
from urllib.parse import quote
from flask import Flask, redirect, request, Response
from jupyterhub.services.auth import HubOAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubOAuth(
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
cache_max_age=60,
)
app = Flask(__name__)
def authenticated(f):
"""Decorator for authenticating with the Hub"""
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get(auth.auth_header_name)
if token:
user = auth.user_for_token(token)
else:
user = None
if user:
return f(user, *args, **kwargs)
else:
# redirect to login url on failed auth
return redirect(auth.login_url + '?next=%s' % quote(request.path))
return decorated
@app.route(prefix)
@authenticated
def whoami(user):
return Response(
json.dumps(user, indent=1, sort_keys=True),
mimetype='application/json',
)
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
:language: python
```
### Authenticating tornado services with JupyterHub
@@ -315,25 +282,38 @@ undefined, then any user will be allowed.
If you don't want to use the reference implementation
(e.g. you find the implementation a poor fit for your Flask app),
you can implement authentication via the Hub yourself.
We recommend looking at the [`HubAuth`][hubauth] class implementation for reference,
JupyterHub is a standard OAuth2 provider,
so you can any OAuth 2 implementation appropriate for
See the [FastAPI example][] for an example of using JupyterHub as an OAuth provider with fastapi,
without using any code imported from JupyterHub.
On completion of OAuth, you will have an access token for JupyterHub,
which can be used to identify the user and the permissions (scopes)
the user has authorized for your service.
You will only get to this stage if the user has the required `access:services!service=$service-name` scope.
To retrieve the user model for the token, make a request to `GET /hub/api/user` with the token in the Authorization header.
For example, using flask:
```{literal-include} ../../../examples/service-whoami-flask/whoami-flask.py
:language: python
```
We recommend looking at the [`HubOAuth`][huboauth] class implementation for reference,
and taking note of the following process:
1. retrieve the cookie `jupyterhub-services` from the request.
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
This request must be authenticated with a Hub API token in the `Authorization` header,
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
1. retrieve the token from the request.
2. Make an API request `GET /hub/api/user`,
with the token in the `Authorization` header.
For example, with [requests][]:
```python
r = requests.get(
'/'.join(["http://127.0.0.1:8081/hub/api",
"authorizations/cookie/jupyterhub-services",
quote(encrypted_cookie, safe=''),
]),
"http://127.0.0.1:8081/hub/api/user",
headers = {
'Authorization' : 'token %s' % api_token,
'Authorization' : f'token {api_token}',
},
)
r.raise_for_status()
@@ -342,13 +322,27 @@ and taking note of the following process:
3. On success, the reply will be a JSON model describing the user:
```json
```python
{
"name": "inara",
"groups": ["serenity", "guild"]
# groups may be omitted, depending on permissions
"groups": ["serenity", "guild"],
# scopes is new in JupyterHub 2.0
"scopes": [
"access:services",
"read:users:name",
"read:users!user=inara",
"..."
]
}
```
The `scopes` field can be used to manage access.
Note: a user will have access to a service to complete oauth access to the service for the first time.
Individual permissions may be revoked at any later point without revoking the token,
in which case the `scopes` field in this model should be checked on each access.
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
An example of using an Externally-Managed Service and authentication is
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
@@ -357,9 +351,9 @@ section on securing the notebook viewer.
[requests]: http://docs.python-requests.org/en/master/
[services_auth]: ../api/services.auth.html
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
[hubauth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler

View File

@@ -1,10 +1,10 @@
# Configuration file for jupyterhub (postgres example).
c = get_config()
c = get_config() # noqa
# Add some users.
c.JupyterHub.admin_users = {'rhea'}
c.Authenticator.whitelist = {'ganymede', 'io', 'rhea'}
c.Authenticator.allowed_users = {'ganymede', 'io', 'rhea'}
# These environment variables are automatically supplied by the linked postgres
# container.

View File

@@ -6,15 +6,17 @@ that appear when JupyterHub renders pages.
To run the service as a hub-managed service simply include in your JupyterHub
configuration file something like:
c.JupyterHub.services = [
```python
c.JupyterHub.services = [
{
'name': 'announcement',
'url': 'http://127.0.0.1:8888',
'command': [sys.executable, "-m", "announcement"],
'command': [sys.executable, "-m", "announcement", "--port", "8888"],
}
]
]
```
This starts the announcements service up at `/services/announcement` when
This starts the announcements service up at `/services/announcement/` when
JupyterHub launches. By default the announcement text is empty.
The `announcement` module has a configurable port (default 8888) and an API
@@ -23,15 +25,28 @@ that environment variable is set or `/` if it is not.
## Managing the Announcement
Admin users can set the announcement text with an API token:
Users with permission can set the announcement text with an API token:
$ curl -X POST -H "Authorization: token <token>" \
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
https://.../services/announcement
https://.../services/announcement/
To grant permission, add a role (JupyterHub 2.0) with access to the announcement service:
```python
# grant the 'announcer' permission to access the announcement service
c.JupyterHub.load_roles = [
{
"name": "announcers",
"users": ["announcer"], # or groups
"scopes": ["access:services!service=announcement"],
}
]
```
Anyone can read the announcement:
$ curl https://.../services/announcement | python -m json.tool
$ curl https://.../services/announcement/ | python -m json.tool
{
announcement: "JupyterHub will be upgraded on August 14!",
timestamp: "...",
@@ -41,10 +56,11 @@ Anyone can read the announcement:
The time the announcement was posted is recorded in the `timestamp` field and
the user who posted the announcement is recorded in the `user` field.
To clear the announcement text, just DELETE. Only admin users can do this.
To clear the announcement text, send a DELETE request.
This has the same permissionOnly admin users can do this.
$ curl -X POST -H "Authorization: token <token>" \
https://.../services/announcement
$ curl -X DELETE -H "Authorization: token <token>" \
https://.../services/announcement/
## Seeing the Announcement in JupyterHub

View File

@@ -13,9 +13,6 @@ from jupyterhub.services.auth import HubAuthenticated
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
"""Dynamically manage page announcements"""
hub_users = []
allow_admin = True
def initialize(self, storage):
"""Create storage for announcement text"""
self.storage = storage

View File

@@ -2,11 +2,18 @@ import sys
# To run the announcement service managed by the hub, add this.
port = 9999
c.JupyterHub.services = [
{
'name': 'announcement',
'url': 'http://127.0.0.1:8888',
'command': [sys.executable, "-m", "announcement"],
'url': f'http://127.0.0.1:{port}',
'command': [
sys.executable,
"-m",
"announcement",
'--port',
str(port),
],
}
]
@@ -14,3 +21,19 @@ c.JupyterHub.services = [
# for an example of how to do this.
c.JupyterHub.template_paths = ["templates"]
c.Authenticator.allowed_users = {"announcer", "otheruser"}
# grant the 'announcer' permission to access the announcement service
c.JupyterHub.load_roles = [
{
"name": "announcers",
"users": ["announcer"],
"scopes": ["access:services!service=announcement"],
}
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -16,6 +16,7 @@ jupyterhub --ip=127.0.0.1
```
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
Login with username 'test-user' and any password.
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
@@ -24,10 +25,10 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/
{"Hello":"World"}
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
{"detail":"Must login with token parameter, cookie, or header"}
{"detail":"Must login with token parameter, or Authorization bearer header"}
$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \
-d '{"username": "myname", "password": "mypasswd!"}' \
$ curl -X POST http://127.0.0.1:8000/hub/api/users/test-user/tokens \
-d '{"auth": {"username": "test-user", "password": "mypasswd!"}}' \
| jq '.token'
"3fee13ce6d2845da9bd5f2c2170d3428"
@@ -35,13 +36,18 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/me \
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
| jq .
{
"name": "myname",
"name": "test-user",
"admin": false,
"groups": [],
"server": null,
"pending": null,
"last_activity": "2021-04-07T18:05:11.587638+00:00",
"servers": null
"last_activity": "2021-05-21T09:13:00.514309+00:00",
"servers": null,
"scopes": [
"access:services",
"access:users:servers!user=test-user",
"...",
]
}
```

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
@@ -22,11 +23,12 @@ class Server(BaseModel):
class User(BaseModel):
name: str
admin: bool
groups: List[str]
groups: Optional[List[str]]
server: Optional[str]
pending: Optional[str]
last_activity: datetime
servers: Optional[List[Server]]
servers: Optional[Dict[str, Server]]
scopes: List[str]
# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses

View File

@@ -1,3 +1,4 @@
import json
import os
from fastapi import HTTPException
@@ -27,6 +28,12 @@ auth_by_header = OAuth2AuthorizationCodeBearer(
### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an
### access_token, which it returns to browser, which places in Authorization header.
if os.environ.get("JUPYTERHUB_OAUTH_SCOPES"):
# typically ["access:services", "access:services!service=$service_name"]
access_scopes = json.loads(os.environ["JUPYTERHUB_OAUTH_SCOPES"])
else:
access_scopes = ["access:services"]
### For consideration: optimize performance with a cache instead of
### always hitting the Hub api?
async def get_current_user(
@@ -58,4 +65,15 @@ async def get_current_user(
},
)
user = User(**resp.json())
if any(scope in user.scopes for scope in access_scopes):
return user
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={
"msg": f"User not authorized: {user.name}",
"request_url": str(resp.request.url),
"token": token,
"user": resp.json(),
},
)

View File

@@ -24,8 +24,21 @@ c.JupyterHub.services = [
"name": service_name,
"url": "http://127.0.0.1:10202",
"command": ["uvicorn", "app:app", "--port", "10202"],
"admin": True,
"oauth_redirect_uri": oauth_redirect_uri,
"environment": {"PUBLIC_HOST": public_host},
}
]
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to services
"scopes": ["self", "access:services"],
},
]
# dummy for testing, create test-user
c.Authenticator.allowed_users = {"test-user"}
c.JupyterHub.authenticator_class = "dummy"
c.JupyterHub.spawner_class = "simple"

View File

@@ -1,33 +1,35 @@
# our user list
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
# ellisonbg and willingc have access to a shared server:
service_name = 'shared-notebook'
service_port = 9999
group_name = 'shared'
c.JupyterHub.load_groups = {'shared-notebook-grp': ['ellisonbg', 'willingc']}
# ellisonbg and willingc are in a group that will access the shared server:
c.JupyterHub.load_roles = [
{
"name": "shared-notebook",
"groups": ["shared-notebook-grp"],
"scopes": ["access:services!service=shared-notebook"],
},
# by default, the user role has access to all services
# we want to limit that, so give users only access to 'self'
{
"name": "user",
"scopes": ["self"],
},
]
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
# start the notebook server as a service
c.JupyterHub.services = [
{
'name': 'shared-notebook',
'url': 'http://127.0.0.1:9999',
'name': service_name,
'url': 'http://127.0.0.1:{}'.format(service_port),
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
}
]
# This "role assignment" is what grants members of the group
# access to the service
c.JupyterHub.load_roles = [
{
"name": "shared-notebook",
"groups": [group_name],
"scopes": [f"access:services!service={service_name}"],
},
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -1,19 +1,35 @@
# our user list
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
# ellisonbg and willingc have access to a shared server:
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
service_name = 'shared-notebook'
service_port = 9999
group_name = 'shared'
# ellisonbg and willingc have access to a shared server:
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
# start the notebook server as a service
c.JupyterHub.services = [
{
'name': service_name,
'url': 'http://127.0.0.1:{}'.format(service_port),
'command': ['jupyterhub-singleuser', '--group=shared', '--debug'],
'command': ['jupyterhub-singleuser', '--debug'],
}
]
# This "role assignment" is what grants members of the group
# access to the service
c.JupyterHub.load_roles = [
{
"name": "shared-notebook",
"groups": [group_name],
"scopes": [f"access:services!service={service_name}"],
},
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -2,15 +2,15 @@
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
There is an implementation each of cookie-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
There is an implementation each of api-token-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
## Run
1. Launch JupyterHub and the `whoami service` with
1. Launch JupyterHub and the `whoami` services with
jupyterhub --ip=127.0.0.1
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
2. Visit http://127.0.0.1:8000/services/whoami-oauth
After logging in with your local-system credentials, you should see a JSON dump of your user info:
@@ -24,15 +24,65 @@ After logging in with your local-system credentials, you should see a JSON dump
}
```
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
and making a direct request:
```bash
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
{
"admin": false,
"created": "2021-05-21T09:47:41.299400Z",
"groups": [],
"kind": "user",
"last_activity": "2021-05-21T09:49:08.290745Z",
"name": "test",
"pending": null,
"roles": [
"user"
],
"scopes": [
"access:services",
"access:users:servers!user=test",
"read:users!user=test",
"read:users:activity!user=test",
"read:users:groups!user=test",
"read:users:name!user=test",
"read:users:servers!user=test",
"read:users:tokens!user=test",
"users!user=test",
"users:activity!user=test",
"users:groups!user=test",
"users:name!user=test",
"users:servers!user=test",
"users:tokens!user=test"
],
"server": null
}
```
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
You may set the `hub_users` configuration in the service script
to restrict access to the service to a whitelist of allowed users.
By default, any authenticated user is allowed.
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
and assign users to the scope.
The jupyterhub_config.py grants access for all users to all services via the default 'user' role, with:
```python
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to all services
"scopes": ["access:services", "self"],
}
]
```
A similar service could be run externally, by setting the JupyterHub service environment variables:
JUPYTERHUB_API_TOKEN
JUPYTERHUB_SERVICE_PREFIX
JUPYTERHUB_OAUTH_SCOPES
JUPYTERHUB_CLIENT_ID # for whoami-oauth only
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.

View File

@@ -2,7 +2,7 @@ import sys
c.JupyterHub.services = [
{
'name': 'whoami',
'name': 'whoami-api',
'url': 'http://127.0.0.1:10101',
'command': [sys.executable, './whoami.py'],
},
@@ -12,3 +12,16 @@ c.JupyterHub.services = [
'command': [sys.executable, './whoami-oauth.py'],
},
]
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to all services
"scopes": ["access:services", "self"],
}
]
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled

View File

@@ -1,6 +1,6 @@
"""An example service authenticating with the Hub.
This example service serves `/services/whoami/`,
This example service serves `/services/whoami-oauth/`,
authenticated with the Hub,
showing the user their own info.
"""
@@ -20,13 +20,6 @@ from jupyterhub.utils import url_path_join
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
# hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# from getpass import getuser
# hub_users = {getuser()}
@authenticated
def get(self):
user_model = self.get_current_user()

View File

@@ -1,6 +1,8 @@
"""An example service authenticating with the Hub.
This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info.
This serves `/services/whoami-api/`, authenticated with the Hub, showing the user their own info.
HubAuthenticated only supports token-based access.
"""
import json
import os
@@ -16,13 +18,6 @@ from jupyterhub.services.auth import HubAuthenticated
class WhoAmIHandler(HubAuthenticated, RequestHandler):
# hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# from getpass import getuser
# hub_users = {getuser()}
@authenticated
def get(self):
user_model = self.get_current_user()

View File

@@ -824,13 +824,26 @@ class UserNotAllowed(Exception):
)
class HubAuthenticated(object):
class HubAuthenticated:
"""Mixin for tornado handlers that are authenticated with JupyterHub
A handler that mixes this in must have the following attributes/properties:
- .hub_auth: A HubAuth instance
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
Default comes from .hub_auth.oauth_scopes,
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
Default values include:
- 'access:services', 'access:services!service={service_name}' for services
- 'access:users:servers', 'access:users:servers!user={user}',
'access:users:servers!server={user}/{server_name}'
for single-user servers
If hub_scopes is not used (e.g. JupyterHub 1.x),
these additional properties can be used:
- .allow_admin: If True, allow any admin user.
Default: False.
- .hub_users: A set of usernames to allow.
If left unspecified or None, username will not be checked.
- .hub_groups: A set of group names to allow.
@@ -943,6 +956,8 @@ class HubAuthenticated(object):
# note: this means successful authentication, but insufficient permission
raise UserNotAllowed(model)
# proceed with the pre-2.0 way if hub_scopes is not set
if self.allow_admin and model.get('admin', False):
app_log.debug("Allowing Hub admin %s", name)
return model