mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 18:44:10 +00:00

Squashed merge of https://github.com/jupyterhub/jupyterhub/pull/4594 Co-authored-by: Samuel Gaist <samuel.gaist@idiap.ch>
288 lines
9.7 KiB
Markdown
288 lines
9.7 KiB
Markdown
(sharing-tutorial)=
|
|
|
|
# Sharing access to your server
|
|
|
|
In JupyterHub 5.0, users can grant each other limited access to their servers without intervention by Hub administrators.
|
|
There is not (yet!) any UI for granting shared access, so this tutorial goes through the steps of using the JupyterHub API to grant access to servers.
|
|
|
|
For more background on how sharing works in JupyterHub, see the [sharing reference documentation](sharing-reference).
|
|
|
|
## Setup: enable sharing (admin)
|
|
|
|
First, sharing must be _enabled_ on the JupyterHub deployment.
|
|
That is, grant (some) users permission to share their servers with others.
|
|
Users cannot share their servers by default.
|
|
This is the only step that requires an admin action.
|
|
To grant users permission to share access to their servers,
|
|
add the `shares!user` scope to the default `user` role:
|
|
|
|
```python
|
|
c.JupyterHub.load_roles = [
|
|
{
|
|
"name": "user",
|
|
"scopes": ["self", "shares!user"],
|
|
},
|
|
]
|
|
```
|
|
|
|
With this, only the sharing via invitation code (described below) will be available.
|
|
|
|
Additionally, if you want users to be able to share access with a **specific user or group** (more below),
|
|
a user must have permission to read that user or group's name.
|
|
To enable the _full_ sharing API for all users:
|
|
|
|
```python
|
|
c.JupyterHub.load_roles = [
|
|
{
|
|
"name": "user",
|
|
"scopes": ["self", "shares!user", "read:users:name", "read:groups:name"],
|
|
},
|
|
]
|
|
```
|
|
|
|
Note that this exposes the ability for all users to _discover_ existing user and group names,
|
|
which is part of why we have the share-by-code pattern,
|
|
so users don't need this ability to share with each other.
|
|
Adding filters lets you limit who can be shared with by name.
|
|
|
|
:::{note}
|
|
Removing a user's permission to grant shares only prevents _future_ shares.
|
|
Any shared permissions previously granted by a user will remain and must be revoked separately,
|
|
if desired.
|
|
:::
|
|
|
|
### Grant servers permission to share themselves (optional, admin)
|
|
|
|
The most natural place to want to grant access to a server is when viewing that server.
|
|
By default, the tokens used when talking to a server have extremely limited permissions.
|
|
You can grant sharing permissions to servers themselves in one of two ways.
|
|
|
|
The first is to grant sharing permission to the tokens used by browser requests.
|
|
This is what you would do if you had a JupyterLab extension that presented UI for managing shares
|
|
(this should exist! We haven't made it yet).
|
|
To grant these tokens sharing permissions:
|
|
|
|
```python
|
|
c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"]
|
|
```
|
|
|
|
JupyterHub's `user-sharing` example does it this way.
|
|
The nice thing about this approach is that only users who already have those permissions will get a token which can take these actions.
|
|
The downside (in terms of convenience) is that the browser token is only accessible to the javascript (e.g. JupyterLab) and/or jupyter-server request handlers,
|
|
but not notebooks or terminals.
|
|
|
|
The second way, which is less secure, but perhaps more convenient for demonstration purposes,
|
|
is to grant the _server itself_ permission to grant access to itself.
|
|
|
|
```python
|
|
c.Spawner.server_token_scopes = [
|
|
"users:activity!user",
|
|
"shares!server",
|
|
]
|
|
```
|
|
|
|
The security downside of this approach is that anyone who can access the server generally can assume the permissions of the server token.
|
|
Effectively, this means anyone who the server is shared _with_ will gain permission to further share the server with others.
|
|
This is not the case for the first approach, but this token is accessible to terminals and notebook kernels, making it easier to illustrate.
|
|
|
|
## Get a token
|
|
|
|
Now, assuming the _user_ has permission to share their server (step 0), we need a token to make the API requests in this tutorial.
|
|
You can do this at the token page, or inherit it from the single-user server environment if one of the above configurations has been selected by admins.
|
|
|
|
To request a token with only the permissions required (`shares!user`) on the token page:
|
|
|
|

|
|
|
|
This token will be in the `Authorization` header.
|
|
To create a {py:class}`requests.Session` that will send this header on every request:
|
|
|
|
```python
|
|
import requests
|
|
from getpass import getpass
|
|
|
|
token = getpass.getpass("JupyterHub API token: ")
|
|
|
|
session = requests.Session()
|
|
session.headers = {"Authorization": f"Bearer {token}"}
|
|
```
|
|
|
|
We will make subsequent requests in this tutorial with this session object, so the header is present.
|
|
|
|
## Issue a sharing code
|
|
|
|
We are going to make a POST request to `/hub/api/share-codes/username/` to issue a _sharing code_.
|
|
This is a _code_, which can be _exchanged_ by one or more users for access to the shared service.
|
|
|
|
A sharing code:
|
|
|
|
- always expires (default: after one day)
|
|
- can be _exchanged_ multiple times for shared access to the server
|
|
|
|
When the sharing code expires, any permissions granted by the code will remain
|
|
(think of it like an invitation to collaborate on a repository or to a chat group - the invitation can expire, but once accepted, access persists).
|
|
|
|
To request a share code:
|
|
|
|
```
|
|
POST /hub/api/share-codes/:username/:servername
|
|
```
|
|
|
|
Assuming your username is `barb` and you want to share access to your default server, this would be:
|
|
|
|
```
|
|
POST /hub/api/share-codes/barb/
|
|
```
|
|
|
|
```python
|
|
# sample values, replace with your actual hub
|
|
hub_url = "http://127.0.0.1:8000"
|
|
username = "barb"
|
|
|
|
r = session.post(f"{hub_url}/hub/api/share-codes/{username}/")
|
|
```
|
|
|
|
which will have a JSON response:
|
|
|
|
```python
|
|
{
|
|
'server': {'user': {'name': 'barb'},
|
|
'name': '',
|
|
'url': '/user/barb/',
|
|
'ready': True,
|
|
},
|
|
'scopes': ['access:servers!server=barb/'],
|
|
'id': 'sc_2',
|
|
'created_at': '2024-01-10T13:01:32.972409Z',
|
|
'expires_at': '2024-01-11T13:01:32.970126Z',
|
|
'exchange_count': 0,
|
|
'last_exchanged_at': None,
|
|
'code': 'U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to',
|
|
'accept_url': '/hub/accept-share?code=U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to',
|
|
}
|
|
```
|
|
|
|
The most relevant fields here are `code`, which contains the code itself, and `accept_url`, which is the URL path for the page another user.
|
|
Note: it does not contain the _hostname_ of the hub, which JupyterHub often does not know.
|
|
|
|
Share codes are guaranteed to be url-safe, so no encoding is required.
|
|
|
|
### Expanding or limiting the share code
|
|
|
|
You can specify scopes (must be limited to this specific server) and expiration of the sharing code.
|
|
|
|
:::{note}
|
|
The granted permissions do not expire, only the code itself.
|
|
That means that after expiration, users may not exchange the code anymore,
|
|
but any user who has exchanged it will still have those permissions.
|
|
:::
|
|
|
|
The _default_ scopes are only `access:servers!server=:user/:server`, and the default expiration is one day (86400).
|
|
These can be overridden in the JSON body of the POST request that issued the token:
|
|
|
|
```python
|
|
import json
|
|
|
|
options = {
|
|
"scopes": [
|
|
f"access:servers!server={username}/", # access the server (default)
|
|
f"servers!server={username}/", # start/stop the server
|
|
f"shares!server={username}/", # further share the server with others
|
|
],
|
|
"expires_in": 3600, # code expires in one hour
|
|
}
|
|
|
|
session.post(f"{hub_url}/hub/api/share-codes/{username}/", data=json.dumps(options))
|
|
```
|
|
|
|
### Distribute the sharing code
|
|
|
|
Now that you have a code and/or a URL, anyone you share the code with will be able to visit `$JUPYTERHUB/hub/accept-share?code=code`.
|
|
|
|
### Sharing a link to a specific page
|
|
|
|
The `accept-share` page also accepts a `next` URL parameter, which can be a redirect to a specific page, rather than the default page of the server.
|
|
For example:
|
|
|
|
```
|
|
/hub/accept-code?code=abc123&next=/users/barb/lab/tree/mynotebook.ipynb
|
|
```
|
|
|
|
would be a link that can be shared with any JupyterHub user that will take them directly to the file `mynotebook.ipynb` in JupyterLab on barb's server after granting them access to the server.
|
|
|
|
## Reviewing shared access
|
|
|
|
When you have shared access to your server, it's a good idea to check out who has access.
|
|
You can see who has access with:
|
|
|
|
```python
|
|
session.get()
|
|
```
|
|
|
|
which produces a paginated list of who has shared access:
|
|
|
|
```python
|
|
{'items': [{'server': {'user': {'name': 'barb'},
|
|
'name': '',
|
|
'url': '/user/barb/',
|
|
'ready': True},
|
|
'scopes': ['access:servers!server=barb/',
|
|
'servers!server=barb/',
|
|
'shares!server=barb/'],
|
|
'user': {'name': 'shared-with'},
|
|
'group': None,
|
|
'kind': 'user',
|
|
'created_at': '2024-01-10T13:16:56.432599Z'}],
|
|
'_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}
|
|
```
|
|
|
|
## Revoking shared access
|
|
|
|
There are two ways to revoke access to a shared server:
|
|
|
|
1. `PATCH` requests can revoke individual permissions from individual users or groups
|
|
2. `DELETE` requests revokes all shared permissions from anyone (unsharing the server in one step)
|
|
|
|
To revoke one or more scopes from a user:
|
|
|
|
```python
|
|
options = {
|
|
"user": "shared-with",
|
|
"scopes": ["shares!server=barb/"],
|
|
}
|
|
|
|
session.patch(f"{hub_url}/hub/api/shares/{username}/", data=json.dumps(options))
|
|
```
|
|
|
|
The Share model with remaining permissions, if any, will be returned:
|
|
|
|
```python
|
|
{'server': {'user': {'name': 'barb'},
|
|
'name': '',
|
|
'url': '/user/barb/',
|
|
'ready': True},
|
|
'scopes': ['access:servers!server=barb/', 'servers!server=barb/'],
|
|
'user': {'name': 'shared-with'},
|
|
'group': None,
|
|
'kind': 'user',
|
|
'created_at': '2024-01-10T13:16:56.432599Z'}
|
|
```
|
|
|
|
If no permissions remain, the response will be an empty dict (`{}`).
|
|
|
|
To revoke all permissions for a single user, leave `scopes` unspecified:
|
|
|
|
```python
|
|
options = {
|
|
"user": "shared-with",
|
|
}
|
|
|
|
session.patch(f"{hub_url}/hub/api/shares/{username}/", data=json.dumps(options))
|
|
```
|
|
|
|
Or revoke all shared permissions from all users for the server:
|
|
|
|
```python
|
|
session.delete(f"{hub_url}/hub/api/shares/{username}/")
|
|
```
|