user-initiated sharing (#4594)

Squashed merge of https://github.com/jupyterhub/jupyterhub/pull/4594

Co-authored-by: Samuel Gaist <samuel.gaist@idiap.ch>
This commit is contained in:
Min RK
2024-02-07 08:34:39 +01:00
committed by GitHub
parent 4555d5bbb2
commit 41fff711e7
36 changed files with 5756 additions and 345 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -178,6 +178,57 @@ Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
```
(access-scopes)=
### Access scopes
An **access scope** is used to govern _access_ to a JupyterHub service or a user's single-user server.
This means making API requests, or visiting via a browser using OAuth.
Without the appropriate access scope, a user or token should not be permitted to make requests of the service.
When you attempt to access a service or server authenticated with JupyterHub, it will begin the [oauth flow](jupyterhub-oauth) for issuing a token that can be used to access the service.
If the user does not have the access scope for the relevant service or server, JupyterHub will not permit the oauth process to complete.
If oauth completes, the token will have at least the access scope for the service.
For minimal permissions, this is the _only_ scope granted to tokens issued during oauth by default,
but can be expanded via {attr}`.Spawner.oauth_client_allowed_scopes` or a service's [`oauth_client_allowed_scopes`](service-credentials) configuration.
:::{seealso}
[Further explanation of OAuth in JupyterHub](jupyterhub-oauth)
:::
If a given service or single-user server can be governed by a single boolean "yes, you can use this service" or "no, you can't," or limiting via other existing scopes, access scopes are enough to manage access to the service.
But you can also further control granular access to servers or services with [custom scopes](custom-scopes), to limit access to particular APIs within the service, e.g. read-only access.
#### Example access scopes
Some example access scopes for services:
access:services
: access to all services
access:services!service=somename
: access to the service named `somename`
and for user servers:
access:servers
: access to all user servers
access:servers!user
: access to all of a user's _own_ servers (never in _resolved_ scopes, but may be used in configuration)
access:servers!user=name
: access to all of `name`'s servers
access:servers!group=groupname
: access to all servers owned by a user in the group `groupname`
access:servers!server
: access to only the issuing server (only relevant when applied to oauth tokens associated with a particular server, e.g. via the {attr}`Spawner.oauth_client_allowed_scopes` configuration.
access:servers!server=username/
: access to only `username`'s _default_ server.
(custom-scopes)=
### Custom scopes

View File

@@ -21,6 +21,7 @@ services
urls
event-logging
monitoring
sharing
gallery-jhub-deployments
changelog
api/index.md

View File

@@ -189,6 +189,8 @@ c.JupyterHub.services = [
In this case, the `url` field will be passed along to the Service as
`JUPYTERHUB_SERVICE_URL`.
(service-credentials)=
## Service credentials
A service has direct access to the Hub API via its `api_token`.

View File

@@ -0,0 +1,398 @@
(sharing-reference)=
# Sharing access to user servers
In order to make use of features like JupyterLab's real-time collaboration (RTC), multiple users must have access to a single server.
There are a few ways to do this, but ultimately both users must have the appropriate `access:servers` scope.
Prior to JupyterHub 5.0, this could only be granted via static role assignments in JupyterHub configuration.
JupyterHub 5.0 adds the concept of a 'share', allowing _users_ to grant each other limited access to their servers.
:::{seealso}
Documentation on [roles and scopes](rbac) for more details on how permissions work in JupyterHub, and in particular [access scopes](access-scopes).
:::
In JupyterHub, shares:
1. are 'granted' to a user or group
2. grant only limited permissions (e.g. only 'access' or access and start/stop)
3. may be revoked by anyone with the `shares` permissions
4. may always be revoked by the shared-with user or group
Additionally a "share code" is a random string, which has all the same properties as a Share aside from the user or group.
The code can be exchanged for actual sharing permission, to enable the pattern of sharing permissions without needing to know the username(s) of who you'd like to share with (e.g. email a link).
There is not yet _UI_ to create shares, but they can be managed via JupyterHub's [REST API](jupyterhub-rest-api).
In general, with shares you can:
1. access other users' servers
2. grant access to your servers
3. see servers shared with you
4. review and revoke permissions for servers you manage
## Enable sharing
For safety, users do not have permission to share access to their servers by default.
To grant this permission, a user must have the `shares` scope for their servers.
To grant all users permission to share access to their servers:
```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, 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.
## Share or revoke access to a server
To modify who has access to a server, you need the permission `shares` with the appropriate _server_ filter,
and access to read the name of the target user or group (`read:users:name` or `read:groups:name`).
You can only modify access to one server at a time.
### Granting access to a server
To grant access to a particular user, in addition to `shares`, the granter must have at least `read:user:name` permission for the target user (or `read:group:name` if it's a group).
Send a POST request to `/api/shares/:username/:servername` to grant permissions.
The JSON body should specify what permissions to grant and whom to grant them to:
```
POST /api/shares/:username/:servername
{
"scopes": [],
"user": "username", # or:
"group": "groupname",
}
```
It should have exactly one of "user" or "group" defined (not both).
The specified user or group will be _granted_ access to the target server.
If `scopes` is specified, all requested scopes _must_ have the `!server=:username/:servername` filter applied.
The default value for `scopes` is `["access:servers!server=:username/:servername"]` (i.e. the 'access scope' for the server).
### Revoke access
To revoke permissions, you need the permission `shares` with the appropriate _server_ filter,
and `read:users:name` (or `read:groups:name`) for the user or group to modify.
You can only modify access to one server at a time.
Send a PATCH request to `/api/shares/:username/:servername` to revoke permissions.
```
PATCH /api/shares/:username/:servername
```
The JSON body should specify the scopes to revoke
```
POST /api/shares/:username/:servername
{
"scopes": [],
"user": "username", # or:
"group": "groupname",
}
```
If `scopes` is empty or unspecified, _all_ scopes are revoked from the target user or group.
#### Revoke _all_ permissions
A DELETE request will revoke all shared access permissions for the given server.
```
DELETE /api/shares/:username/:servername
```
### View shares for a server
To view shares for a given server, you need the permission `read:shares` with the appropriate _server_ filter.
```
GET /api/shares/:username/:servername
```
This is a paginated endpoint, so responses has `items` as a list of Share models, and `_pagination` for information about retrieving all shares if there are many:
```python
{
"items": [
{
"server": {...},
"scopes": ["access:servers!server=sharer/"],
"user": {
"name": "shared-with",
},
"group": None, # or {"name": "groupname"},
...
},
...
],
"_pagination": {
"total": 5,
"limit": 50,
"offset": 0,
"next": None,
},
}
```
see the [rest-api](rest-api) for full details of the response models.
### View servers shared with user or group
To review servers shared with a given user or group, you need the permission `read:users:shares` or `read:groups:shares` with the appropriate _user_ or _group_ filter.
```
GET /api/users/:username/shared
# or
GET /api/groups/:groupname/shared
```
These are paginated endpoints.
### Access permission for a single user on a single server
```
GET /api/users/:username/shared/:ownername/:servername
# or
GET /api/groups/:groupname/shared/:ownername/:servername
```
will return the _single_ Share info for the given user or group for the server specified by `ownername/servername`,
or 404 if no access is granted.
### Revoking one's own permissions for a server
To revoke sharing permissions from the perspective of the user or group being shared with,
you need the permissions `users:shares` or `groups:shares` with the appropriate _user_ or _group_ filter.
This allows users to 'leave' shared servers, without needing permission to manage the server's sharing permissions.
```
DELETE /api/users/:username/shared/:ownername/:servername
# or
DELETE /api/groups/:groupname/shared/:ownername/:servername
```
will revoke all permissions granted to the user or group for the specified server.
### The Share model
<!-- refresh from examples/user-sharing/rest-api.ipynb -->
A Share returned in the REST API has the following structure:
```python
{
"server": {
"name": "servername",
"user": {
"name": "ownername"
},
"url": "/users/ownername/servername/",
"ready": True,
},
"scopes": ["access:servers!server=username/servername"],
"user": { # or None
"name": "username",
},
"group": None, # or {"name": "groupname"},
"created_at": "2023-10-02T13:27Z",
}
```
where exactly one of `user` and `group` is not null and the other is null.
See the [rest-api](rest-api) for full details of the response models.
## Share via invitation code
Sometimes you would like to share access to a server with one or more users,
but you don't want to deal with collecting everyone's username.
For this, you can create shares via _share code_.
This is identical to sharing with a user,
only it adds the step where the sharer creates the _code_ and distributes the code to one or more users,
then the users themselves exchange the code for actual sharing permissions.
Share codes are much like shares, except:
1. they don't associate with specific users
2. they can be used multiple times, by more than one user (i.e. send one invite email to several recipients)
3. they expire (default: 1 day)
4. they can only be accepted by individual users, not groups
### Creating share codes
To create a share code:
```
POST /api/share-code/:username/:servername
```
where the body should include the scopes to be granted and expiration.
Share codes _must_ expire.
```python
{
"scopes": ["access:servers!server=:user/:server"],
"expires_in": 86400, # seconds, default: 1 day
}
```
If no scopes are specified, the access scope for the specified server will be used.
If no expiration is specified, the code will expire in one day (86400 seconds).
The response contains the code itself:
```python
{
"code": "abc1234....",
"accept_url": "/hub/accept-share?code=abc1234",
"id": "sc_1234",
"scopes": [...],
...
}
```
See the [rest-api](rest-api) for full details of the response models.
### Accepting sharing invitations
Sharing invitations can be accepted by visiting:
```
/hub/accept-share/?code=:share-code
```
where you will be able to confirm the permissions you would like to accept.
After accepting permissions, you will be redirected to the running server.
If the server is not running and you have not also been granted permission to start it,
you will need to contact the owner of the server to start it.
### Listing existing invitations
You can see existing invitations for
```
GET /hub/api/share-codes/:username/:servername
```
which produces a paginated list of share codes (_excluding_ the codes themselves, which are not stored by jupyterhub):
```python
{
"items": [
{
"id": "sc_1234",
"exchange_count": 0,
"last_exchanged_at": None,
"scopes": ["access:servers!server=username/servername"],
"server": {
"name": "",
"user": {
"name": "username",
},
},
...
}
],
"_pagination": {
"total": 5,
"limit": 50,
"offset": 0,
"next": None,
}
}
```
see the [rest-api](rest-api) for full details of the response models.
### Share code model
<!-- refresh from examples/user-sharing/rest-api.ipynb -->
A Share Code returned in the REST API has most of the same fields as a Share, but lacks the association with a user or group, and adds information about exchanges of the share code,
and the `id` that can be used for revocation:
```python
{
# common share fields
"server": {
"user": {
"name": "sharer"
},
"name": "",
"url": "/user/sharer/",
"ready": True,
},
"scopes": [
"access:servers!server=sharer/"
],
# share-code-specific fields
"id": "sc_1",
"created_at": "2024-01-23T11:46:32.154416Z",
"expires_at": "2024-01-24T11:46:32.153582Z",
"exchange_count": 1,
"last_exchanged_at": "2024-01-23T11:46:43.589701Z"
}
```
see the [rest-api](rest-api) for full details of the response models.
### Revoking invitations
If you've finished inviting users to a server, you can revoke all invitations with:
```
DELETE /hub/api/share-codes/:username/:servername
```
or revoke a single invitation code:
```
DELETE /hub/api/share-codes/:username/:servername?code=:thecode
```
You can also revoke a code by _id_, if you non longer have the code:
```
DELETE /hub/api/share-codes/:username/:servername?id=sc_123
```
where the `id` is retrieved from the share-code model, e.g. when listing current share codes.

View File

@@ -51,5 +51,6 @@ Further tutorials of configuring JupyterHub for specific tasks
```{toctree}
:maxdepth: 1
sharing
collaboration-users
```

View File

@@ -0,0 +1,287 @@
(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:
![JupyterHub Token page requesting a token with scopes "shares!user"](../images/sharing-token.png)
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}/")
```