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

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@ docs/source/rbac/scope-table.md
docs/source/reference/metrics.md
.ipynb_checkpoints
.virtual_documents
jsx/build/
# ignore config file at the top-level of the repo
# but not sub-dirs

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}/")
```

View File

@@ -0,0 +1,77 @@
# User-initiated sharing
This example contains a jupyterhub configuration and sample notebooks demonstrating user-initiated sharing from within a JupyterLab session.
What _admins_ need to do is enable sharing:
```python
c.JupyterHub.load_roles = [
{
"name": "user",
"scopes": ["self", "shares!user"],
}
]
```
## Getting a token with sharing permission
Users can always issue themselves tokens with the desired permissions.
But for a deployment, it's likely that you want to grant sharing permission to something,
be it a service or some part of the single-user application.
There are two ways to do this in a single-user session,
and for convenience, this example includes both.
In most real deployments, it will only make sense to do one or the other.
### Sharing via JupyterLab extension
If you have a JupyterLab javascript sharing extension or server extension,
sharing permissions should be granted to the oauth tokens used to visit the single-user server.
These permissions can be specified:
```python
# OAuth token should have sharing permissions,
# so JupyterLab javascript can manage shares
c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"]
```
The notebook `share-jupyterlab.ipynb` contains a few javascript snippets which will use the JupyterLab configuration to make API requests to JupyterHub from javascript in order to grant access.
This workflow _should_ be handled by a proper JupyterLab extension,
but this notebook of javascript snippets serves as a proof of concept for what is required to build such an extension.
## Sharing via API token
These same permissions can also be granted to the server token itself,
which is available as $JUPYTERHUB_API_TOKEN in the server,
as well as terminals and notebooks.
```python
# grant $JUPYTERHUB_API_TOKEN sharing permissions
# so that _python_ code can manage shares
c.Spawner.server_token_scopes = [
"shares!server", # manage shares
"servers!server", # start/stop itself
"users:activity!server", # report activity (default permission)
]
```
This method is not preferable, because it means anyone with _access_ to the server also has access to the token to grant further sharing permissions,
which is not the case when using the oauth permissions above,
where each visiting user has their own permissions.
But it is more convenient for demonstration purposes, because we can write a Python notebook to use it, share-api.ipynb.
## Run the example
First, launch jupyterhub: `jupyterhub`.
Then login as the user `sharer`.
Run the first couple of cells of the notebook, until you get a `/hub/accept-share` URL.
Open a new private browser window, and paste this URL. When prompted, login with the username `shared-with`.
In the end, you should arrive at `sharer`'s server as the user `shared-with`.
After visiting as `shared-with`, you can proceed in the notebook as `sharer` and view who has permissions, revoke share codes, permissions, etc.

View File

@@ -0,0 +1,37 @@
c = get_config() # noqa
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.Authenticator.allowed_users = {"sharer", "shared-with"}
# put the current directory on sys.path for shareextension.py
from pathlib import Path
here = Path(__file__).parent.absolute()
c.Spawner.notebook_dir = str(here)
# users need sharing permissions for their own servers
c.JupyterHub.load_roles = [
{
"name": "user",
"scopes": ["self", "shares!user"],
},
]
# below are two ways to grant sharing permission to a single-user server.
# there's no reason to use both
# OAuth token should have sharing permissions,
# so JupyterLab javascript can manage shares
c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"]
# grant $JUPYTERHUB_API_TOKEN sharing permissions
# so that _python_ code can manage shares
c.Spawner.server_token_scopes = [
"shares!server", # manage shares
"servers!server", # start/stop itself
"users:activity!server", # report activity
]

View File

@@ -0,0 +1,685 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "8c5bd1ca-3329-4062-8851-9bd33009d805",
"metadata": {},
"source": [
"# Using the sharing API from Python \n",
"\n",
"In this example, we use $JUPYTERHUB_API_TOKEN to communicate with the sharing API via Python.\n",
"\n",
"The permissions used here are granted via the `c.Spawner.server_token_scopes` config in jupyterhub_config.py\n",
"\n",
"By using this token, any user who has access to this server has access to sharing permissions.\n",
"\n",
"First, get some useful configuration from the server environment:\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "95fa629b-ac65-46da-86a6-9798f3d0a2ba",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'http://127.0.0.1:8081/hub/api'"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import os\n",
"\n",
"hub_api = os.environ[\"JUPYTERHUB_API_URL\"]\n",
"token = os.environ[\"JUPYTERHUB_API_TOKEN\"]\n",
"username = os.environ[\"JUPYTERHUB_USER\"]\n",
"user_server = f\"{username}/{os.environ['JUPYTERHUB_SERVER_NAME']}\"\n",
"hub_host = os.environ[\"JUPYTERHUB_HOST\"]\n",
"server_base_url = os.environ[\"JUPYTERHUB_SERVICE_PREFIX\"]\n",
"\n",
"hub_api"
]
},
{
"cell_type": "markdown",
"id": "456697ab-e7ce-4f75-bc8f-3ce424830e0d",
"metadata": {},
"source": [
"Create a requests.Session to make jupyterhub API requests with our token"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "f3335eeb-64e5-4caa-acb5-1032aaf727bb",
"metadata": {},
"outputs": [],
"source": [
"import requests\n",
"\n",
"session = requests.Session()\n",
"session.headers = {\"Authorization\": f\"Bearer {token}\"}"
]
},
{
"cell_type": "markdown",
"id": "3cf5605b-2022-4d2a-b0cc-de6f80e8f2fe",
"metadata": {},
"source": [
"We can check the permissions our token has with a request to /hub/api/user:"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "17529af3-5ec8-4495-9183-bd5d423a1d76",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'kind': 'user',\n",
" 'last_activity': '2024-01-23T11:43:50.800864Z',\n",
" 'groups': [],\n",
" 'admin': False,\n",
" 'name': 'sharer',\n",
" 'servers': {'': {'name': '',\n",
" 'full_name': 'sharer/',\n",
" 'last_activity': '2024-01-23T11:43:50.800864Z',\n",
" 'started': '2024-01-23T11:28:44.948553Z',\n",
" 'pending': None,\n",
" 'ready': True,\n",
" 'stopped': False,\n",
" 'url': '/user/sharer/',\n",
" 'user_options': {},\n",
" 'progress_url': '/hub/api/users/sharer/server/progress'}},\n",
" 'session_id': None,\n",
" 'scopes': ['access:servers!server=sharer/',\n",
" 'delete:servers!server=sharer/',\n",
" 'groups:shares!server=sharer/',\n",
" 'read:groups:shares!server=sharer/',\n",
" 'read:servers!server=sharer/',\n",
" 'read:shares!server=sharer/',\n",
" 'read:users:activity!user=sharer',\n",
" 'read:users:groups!user=sharer',\n",
" 'read:users:name!user=sharer',\n",
" 'servers!server=sharer/',\n",
" 'shares!server=sharer/',\n",
" 'users:activity!server=sharer/',\n",
" 'users:activity!user=sharer',\n",
" 'users:shares!server=sharer/']}"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"r = session.get(f\"{hub_api}/user\")\n",
"r.json()"
]
},
{
"cell_type": "markdown",
"id": "a14578b8-577e-4b0a-b74c-40d7a04a1a1a",
"metadata": {},
"source": [
"We can see who has access to this server:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "63520e3c-3621-4ebf-9775-52e6150ccca5",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'items': [],\n",
" '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"shares_url = f\"{hub_api}/shares/{user_server}\"\n",
"share_codes_url = f\"{hub_api}/share-codes/{user_server}\"\n",
"r = session.get(shares_url)\n",
"r.json()"
]
},
{
"cell_type": "markdown",
"id": "5c8870d0-5b7e-4065-b227-fd463289cfdf",
"metadata": {},
"source": [
"and if there are any outstanding codes:"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "6fad5334-6034-48f8-b008-1fd3d6e4d139",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'items': [],\n",
" '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"r = session.get(share_codes_url)\n",
"r.json()"
]
},
{
"cell_type": "markdown",
"id": "e6d598b3-5eab-4d56-b2ea-9aec345a3948",
"metadata": {},
"source": [
"Next, we can create a code:"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "facb1872-44a5-4e4e-84d4-1fd829b1a27b",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'server': {'user': {'name': 'sharer'},\n",
" 'name': '',\n",
" 'url': '/user/sharer/',\n",
" 'ready': True},\n",
" 'scopes': ['access:servers!server=sharer/'],\n",
" 'id': 'sc_1',\n",
" 'created_at': '2024-01-23T11:46:32.154416Z',\n",
" 'expires_at': '2024-01-24T11:46:32.153582Z',\n",
" 'exchange_count': 0,\n",
" 'last_exchanged_at': None,\n",
" 'code': 'gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg',\n",
" 'accept_url': '/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg'}"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"r = session.post(share_codes_url)\n",
"code_info = r.json()\n",
"code_info"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "83e23f83-18cb-4e54-9683-68c2ab0fcfc2",
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"\n",
" <a href='/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg'>share this link to grant access</a>\n",
" "
],
"text/plain": [
"<IPython.core.display.HTML object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"from IPython.display import display, HTML\n",
"full_accept_url = f\"{hub_host}{code_info['accept_url']}\"\n",
"\n",
"display(\n",
" HTML(f\"\"\"\n",
" <a href='{full_accept_url}'>share this link to grant access</a>\n",
" \"\"\")\n",
")\n"
]
},
{
"cell_type": "markdown",
"id": "0ce4f6f8-a0f3-4556-ab25-1f86ef49a6db",
"metadata": {},
"source": [
"(in jupyterlab, shift-right-click to copy link)\n",
"\n",
"We can now give this to the shared-with user (i.e. us in another private browsing tab).\n",
"\n",
"After accepting the link, we can see who we've shared with again:"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "e056a9f0-7cec-414b-a3ce-03c8abfbc087",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'items': [{'server': {'user': {'name': 'sharer'},\n",
" 'name': '',\n",
" 'url': '/user/sharer/',\n",
" 'ready': True},\n",
" 'scopes': ['access:servers!server=sharer/'],\n",
" 'user': {'name': 'shared-with'},\n",
" 'group': None,\n",
" 'kind': 'user',\n",
" 'created_at': '2024-01-23T11:46:43.585455Z'}],\n",
" '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"session.get(shares_url).json()"
]
},
{
"cell_type": "markdown",
"id": "9933ef79-3f59-45fc-92a9-13e7bc1f3b82",
"metadata": {},
"source": [
"The share code can also include a `?next=` url parameter, to enable a link to take users to a specific file or view after accepting the code:"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "9c313d99-4dc9-45af-9643-f2b0e08b2bb4",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg&next=%2Fuser%2Fsharer%2Flab%2Ftree%2Fshare-api.ipynb\n"
]
},
{
"data": {
"text/html": [
"\n",
" share <a href='/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg&next=%2Fuser%2Fsharer%2Flab%2Ftree%2Fshare-api.ipynb'>this link</a>\n",
" to grant access and direct users to this notebook\n",
" "
],
"text/plain": [
"<IPython.core.display.HTML object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"from urllib.parse import urlencode\n",
"\n",
"this_notebook_url = server_base_url + \"lab/tree/share-api.ipynb\"\n",
"this_notebook_accept_url = full_accept_url + \"&\" + urlencode({\"next\": this_notebook_url})\n",
"print(this_notebook_accept_url)\n",
"\n",
"display(\n",
" HTML(f\"\"\"\n",
" share <a href='{this_notebook_accept_url}'>this link</a>\n",
" to grant access and direct users to this notebook\n",
" \"\"\")\n",
")\n"
]
},
{
"cell_type": "markdown",
"id": "2455ba21-f558-4391-b8fc-6501feb3bbfb",
"metadata": {},
"source": [
"## Reviewing and managing access\n",
"\n",
"Listing share codes doesn't reveal the code - if you need to get a code, issue a new sharing code.\n",
"\n",
"But we can see in `exchange_count` whether and how often the code has been used"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "313116ed-2aa5-4a0a-bc91-f15a9428ae0c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'items': [{'server': {'user': {'name': 'sharer'},\n",
" 'name': '',\n",
" 'url': '/user/sharer/',\n",
" 'ready': True},\n",
" 'scopes': ['access:servers!server=sharer/'],\n",
" 'id': 'sc_1',\n",
" 'created_at': '2024-01-23T11:46:32.154416Z',\n",
" 'expires_at': '2024-01-24T11:46:32.153582Z',\n",
" 'exchange_count': 1,\n",
" 'last_exchanged_at': '2024-01-23T11:46:43.589701Z'}],\n",
" '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}"
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"session.get(share_codes_url).json()"
]
},
{
"cell_type": "markdown",
"id": "5f0d4d8e-fabb-4423-b9bd-d96643a9b9f7",
"metadata": {},
"source": [
"we can also revoke the code. Codes can be deleted by code or id"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "666ac28c-70f5-4a75-9bfa-6d26c5ea610f",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<Response [204]>"
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"session.delete(share_codes_url + f\"?id={code_info['id']}\")"
]
},
{
"cell_type": "markdown",
"id": "ce2fdb7f-a2de-4c95-996b-527ce0536794",
"metadata": {},
"source": [
"or if you're done sharing via code, you can delete all sharing codes for a server without looking it up their ids:"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "4633b0af-ad75-4e72-9702-9cb16182aecb",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<Response [204]>"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"session.delete(share_codes_url)"
]
},
{
"cell_type": "markdown",
"id": "c322e058-bbf5-4058-8e20-415c7da0aa8a",
"metadata": {},
"source": [
"scopes and expiration can be customized in the request when creating the share code:"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "462bcec7-a899-4e3e-8e77-7d08adedb7a4",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<Response [200]>"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import json\n",
"\n",
"options = {\n",
" \"scopes\": [\n",
" f\"access:servers!server={user_server}\", # access the server (default)\n",
" f\"servers!server={user_server}\", # start/stop the server\n",
" f\"shares!server={user_server}\", # further share the server with others\n",
" ],\n",
" \"expires_in\": 3600, # code expires in one hour\n",
"}\n",
"\n",
"\n",
"session.post(share_codes_url, data=json.dumps(options))\n"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "c8babe6c-6339-4990-9241-d3db54205108",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'items': [{'server': {'user': {'name': 'sharer'},\n",
" 'name': '',\n",
" 'url': '/user/sharer/',\n",
" 'ready': True},\n",
" 'scopes': ['access:servers!server=sharer/'],\n",
" 'user': {'name': 'shared-with'},\n",
" 'group': None,\n",
" 'kind': 'user',\n",
" 'created_at': '2024-01-23T11:46:43.585455Z'}],\n",
" '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}"
]
},
"execution_count": 14,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"r = session.get(shares_url)\n",
"r.json()"
]
},
{
"cell_type": "markdown",
"id": "616d8f46-940d-4527-98a7-27894a23974f",
"metadata": {},
"source": [
"## Revoking permissions\n",
"\n",
"We can revoke specific permssions via a PATCH request\n",
"by specifying the user (or group) and one or more scopes:"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "13d54943-e95a-4222-a67d-8f3832b70e3c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<Response [200]>"
]
},
"execution_count": 15,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"options = {\n",
" \"user\": \"shared-with\",\n",
" \"scopes\": ['shares!server=sharer/'],\n",
"}\n",
"\n",
"session.patch(shares_url, data=json.dumps(options))"
]
},
{
"cell_type": "markdown",
"id": "0b2b2619-3bb4-41a7-852b-de019f49185e",
"metadata": {},
"source": [
"If scopes are unspecified, all permissions are revoked:"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "1d8997fa-7093-44dd-abdf-405fe8cc7fcd",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<Response [200]>"
]
},
"execution_count": 16,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"options = {\n",
" \"user\": \"shared-with\",\n",
"}\n",
"\n",
"session.patch(shares_url, data=json.dumps(options))\n"
]
},
{
"cell_type": "markdown",
"id": "bb4e37a4-d206-420b-ad21-93a15c65bbd9",
"metadata": {},
"source": [
"_All_ shared access can be revoked via a DELETE request to the shares URL:"
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "203ec184-df77-493b-bd8f-e6bb461abe7e",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<Response [204]>"
]
},
"execution_count": 17,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"session.delete(shares_url)"
]
},
{
"cell_type": "markdown",
"id": "129d98a3-df16-4607-8a9d-784843a7eeaf",
"metadata": {},
"source": [
"and we can see that nobody has shared access anymore"
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "d40f97b8-171f-4958-9cbf-bcc08a5f0db7",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'items': [],\n",
" '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}"
]
},
"execution_count": 18,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"session.get(shares_url).json()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,403 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "7609a68a-c01b-43a8-90aa-3d004af614dd",
"metadata": {},
"source": [
"# Sharing access to a server\n",
"\n",
"This notebook executes some javascript in the browser, using the user's OAuth token.\n",
"\n",
"This code would normally reside in a jupyterlab extension.\n",
"The notebook serves only for demonstration purposes.\n",
"\n",
"First, collect some configuration from the page, so we can talk to the JupyterHub API:"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "b3cf16bd-ff9b-4140-a394-5a7ca5d96d88",
"metadata": {},
"outputs": [
{
"data": {
"application/javascript": [
"// define some globals to share\n",
"\n",
"var configElement = document.getElementById(\"jupyter-config-data\");\n",
"var jupyterConfig = JSON.parse(configElement.innerHTML);\n",
"\n",
"window.token = jupyterConfig.token;\n",
"window.hubOrigin = `${document.location.protocol}//${jupyterConfig.hubHost || window.location.host}`\n",
"window.hubUrl = `${hubOrigin}${jupyterConfig.hubPrefix}`;\n",
"window.shareCodesUrl = `${hubUrl}api/share-codes/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
"window.sharesUrl = `${hubUrl}api/shares/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
"console.log(shareCodesUrl);\n",
"\n",
"// utility function to make API requests and parse errors\n",
"window.apiRequest = async function (url, options) {\n",
" var element = options.element;\n",
" var okStatus = options.ok || 200;\n",
" var resp = await fetch(url, {headers: {Authorization: `Bearer ${token}`}, method: options.method || 'GET'});\n",
" var replyText = await resp.text();\n",
" var replyJSON = {};\n",
" if (replyText.length) {\n",
" replyJSON = JSON.parse(replyText);\n",
" }\n",
" \n",
" if (resp.status != okStatus) {\n",
" var p = document.createElement('p');\n",
" p.innerText = `Error ${resp.status}: ${replyJSON.message}`;\n",
" element.appendChild(p);\n",
" return;\n",
" }\n",
" return replyJSON;\n",
"}\n",
"\n",
"// `element` is a special variable for the current cell's output area\n",
"element.innerText = `API URL for sharing codes is: ${shareCodesUrl}`;\n"
],
"text/plain": [
"<IPython.core.display.Javascript object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"%%javascript\n",
"// define some globals to share\n",
"\n",
"var configElement = document.getElementById(\"jupyter-config-data\");\n",
"var jupyterConfig = JSON.parse(configElement.innerHTML);\n",
"\n",
"window.token = jupyterConfig.token;\n",
"window.hubOrigin = `${document.location.protocol}//${jupyterConfig.hubHost || window.location.host}`\n",
"window.hubUrl = `${hubOrigin}${jupyterConfig.hubPrefix}`;\n",
"window.shareCodesUrl = `${hubUrl}api/share-codes/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
"window.sharesUrl = `${hubUrl}api/shares/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n",
"console.log(shareCodesUrl);\n",
"\n",
"// utility function to make API requests and parse errors\n",
"window.apiRequest = async function (url, options) {\n",
" var element = options.element;\n",
" var okStatus = options.ok || 200;\n",
" var resp = await fetch(url, {headers: {Authorization: `Bearer ${token}`}, method: options.method || 'GET'});\n",
" var replyText = await resp.text();\n",
" var replyJSON = {};\n",
" if (replyText.length) {\n",
" replyJSON = JSON.parse(replyText);\n",
" }\n",
" \n",
" if (resp.status != okStatus) {\n",
" var p = document.createElement('p');\n",
" p.innerText = `Error ${resp.status}: ${replyJSON.message}`;\n",
" element.appendChild(p);\n",
" return;\n",
" }\n",
" return replyJSON;\n",
"}\n",
"\n",
"// `element` is a special variable for the current cell's output area\n",
"element.innerText = `API URL for sharing codes is: ${shareCodesUrl}`;"
]
},
{
"cell_type": "markdown",
"id": "b2049fa1-bb60-4073-9167-2e116b198f0e",
"metadata": {},
"source": [
"Next, we can request a share code with\n",
"\n",
"```\n",
"POST $hub/api/share-codes/$user/$server\n",
"```\n",
"\n",
"The URL for _accepting_ a sharing invitation code is `/hub/accept-share?code=abc123...`:"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "ee9430f3-4866-41f7-942d-423bd48dc6b8",
"metadata": {},
"outputs": [
{
"data": {
"application/javascript": [
"\n",
"(async function f() {\n",
" var shareCode = await apiRequest(shareCodesUrl, {method: 'POST', element: element});\n",
"\n",
" // laziest way to display\n",
" var shareCodeUrl = `${hubOrigin}${shareCode.accept_url}`\n",
" var a = document.createElement('a');\n",
" a.href = shareCodeUrl;\n",
" a.innerText = shareCodeUrl;\n",
" var p = document.createElement(p);\n",
" p.append(\"Share this URL to grant access to this server: \");\n",
" p.appendChild(a);\n",
" element.appendChild(p);\n",
"})();\n",
"\n"
],
"text/plain": [
"<IPython.core.display.Javascript object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"%%javascript\n",
"\n",
"(async function f() {\n",
" var shareCode = await apiRequest(shareCodesUrl, {method: 'POST', element: element});\n",
"\n",
" // laziest way to display\n",
" var shareCodeUrl = `${hubOrigin}${shareCode.accept_url}`\n",
" var a = document.createElement('a');\n",
" a.href = shareCodeUrl;\n",
" a.innerText = shareCodeUrl;\n",
" var p = document.createElement(p);\n",
" p.append(\"Share this URL to grant access to this server: \");\n",
" p.appendChild(a);\n",
" element.appendChild(p);\n",
"})();\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "418152a0-2e0f-4ca6-8b53-9295d15c4345",
"metadata": {},
"source": [
"Share this URL to grant access to your server (e.g. visit the URL in a private window and login as the user `shared-with`).\n",
"\n",
"After our code has been used, we can see who has access to this server:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "5c9c6e1b-ccab-4c85-8afb-db141651236a",
"metadata": {},
"outputs": [
{
"data": {
"application/javascript": [
"\n",
"(async function f() {\n",
"\n",
" var shares = await apiRequest(sharesUrl, {element: element});\n",
"\n",
" var list = document.createElement('ul');\n",
" for (var share of shares.items) {\n",
" var p = document.createElement('li');\n",
" p.append(`${share.kind} ${share[share.kind].name} has access: `)\n",
" var scopes = document.createElement('tt');\n",
" scopes.innerText = share.scopes.join(',');\n",
" p.appendChild(scopes);\n",
" list.append(p);\n",
" }\n",
" var p = document.createElement('p');\n",
" p.innerText = `Shared with ${shares.items.length} users:`;\n",
" element.appendChild(p);\n",
" element.appendChild(list);\n",
" return;\n",
"})();\n"
],
"text/plain": [
"<IPython.core.display.Javascript object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"%%javascript\n",
"\n",
"(async function f() {\n",
"\n",
" var shares = await apiRequest(sharesUrl, {element: element});\n",
"\n",
" var list = document.createElement('ul');\n",
" for (var share of shares.items) {\n",
" var p = document.createElement('li');\n",
" p.append(`${share.kind} ${share[share.kind].name} has access: `)\n",
" var scopes = document.createElement('tt');\n",
" scopes.innerText = share.scopes.join(',');\n",
" p.appendChild(scopes);\n",
" list.append(p);\n",
" }\n",
" var p = document.createElement('p');\n",
" p.innerText = `Shared with ${shares.items.length} users:`;\n",
" element.appendChild(p);\n",
" element.appendChild(list);\n",
" return;\n",
"})();\n"
]
},
{
"cell_type": "markdown",
"id": "18165205-67f3-44d4-ad6b-40ab27ec469c",
"metadata": {},
"source": [
"We could also use this info to revoke permissions, or share with individuals by name.\n",
"\n",
"We can also review outstanding sharing _codes_:"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "40cd1e2d-fc21-4238-8cbc-9ae45be248c9",
"metadata": {},
"outputs": [
{
"data": {
"application/javascript": [
"\n",
"(async function f() {\n",
" var shareCodes = await apiRequest(shareCodesUrl, {element: element});\n",
" var p = document.createElement('pre');\n",
" p.innerText = JSON.stringify(shareCodes.items, null, ' ');\n",
" element.appendChild(p);\n",
"})();\n"
],
"text/plain": [
"<IPython.core.display.Javascript object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"%%javascript\n",
"\n",
"(async function f() {\n",
" var shareCodes = await apiRequest(shareCodesUrl, {element: element});\n",
" var p = document.createElement('pre');\n",
" p.innerText = JSON.stringify(shareCodes.items, null, ' ');\n",
" element.appendChild(p);\n",
"})();\n"
]
},
{
"cell_type": "markdown",
"id": "0effee22-c132-4b44-a677-c4375594c462",
"metadata": {},
"source": [
"And finally, when we're done, we can revoke the codes, at which point nobody _new_ can use the code to gain access to this server,\n",
"but anyone who has accepted the code will still have access:"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "51a795f5-9a74-49b1-9ef3-e7978da0cc44",
"metadata": {},
"outputs": [
{
"data": {
"application/javascript": [
"\n",
"(async function f() {\n",
" await apiRequest(shareCodesUrl, {method: 'DELETE', element: element, ok: 204});\n",
" var p = document.createElement('p');\n",
" p.innerText = `Deleted all share codes`;\n",
" element.appendChild(p); \n",
"})();\n"
],
"text/plain": [
"<IPython.core.display.Javascript object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"%%javascript\n",
"\n",
"(async function f() {\n",
" await apiRequest(shareCodesUrl, {method: 'DELETE', element: element, ok: 204});\n",
" var p = document.createElement('p');\n",
" p.innerText = `Deleted all share codes`;\n",
" element.appendChild(p); \n",
"})();"
]
},
{
"cell_type": "markdown",
"id": "2ad12718-1f68-4d2f-9934-dd6bd4555a1d",
"metadata": {},
"source": [
"Or even revoke all shared access, so anyone who may have used the code no longer has any access:"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "efa5ae15-ac99-4bf4-b7b4-3d93747dba8c",
"metadata": {},
"outputs": [
{
"data": {
"application/javascript": [
"\n",
"(async function f() {\n",
" var resp = await apiRequest(sharesUrl, {method: 'DELETE', element: element, ok: 204});\n",
" var p = document.createElement('p');\n",
" p.innerText = `Deleted all shared access`;\n",
" element.appendChild(p); \n",
"})();\n"
],
"text/plain": [
"<IPython.core.display.Javascript object>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"%%javascript\n",
"\n",
"(async function f() {\n",
" var resp = await apiRequest(sharesUrl, {method: 'DELETE', element: element, ok: 204});\n",
" var p = document.createElement('p');\n",
" p.innerText = `Deleted all shared access`;\n",
" element.appendChild(p); \n",
"})();"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -1,6 +1,6 @@
from . import auth, groups, hub, proxy, services, users
from . import auth, groups, hub, proxy, services, shares, users
from .base import * # noqa
default_handlers = []
for mod in (auth, hub, proxy, users, groups, services):
for mod in (auth, hub, proxy, users, groups, services, shares):
default_handlers.extend(mod.default_handlers)

View File

@@ -200,6 +200,7 @@ class APIHandler(BaseHandler):
model = {
'name': orm_spawner.name,
'full_name': f"{orm_spawner.user.name}/{orm_spawner.name}",
'last_activity': isoformat(orm_spawner.last_activity),
'started': isoformat(orm_spawner.started),
'pending': pending,

View File

@@ -0,0 +1,590 @@
"""Handlers for Shares and Share Codes"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import re
from typing import List, Optional
from pydantic import (
BaseModel,
ConfigDict,
ValidationError,
conint,
field_validator,
model_validator,
)
from sqlalchemy import or_
from sqlalchemy.orm import joinedload
from tornado import web
from tornado.httputil import url_concat
from .. import orm
from ..scopes import _check_scopes_exist, needs_scope
from ..utils import isoformat
from .base import APIHandler
from .groups import _GroupAPIHandler
_share_code_id_pat = re.compile(r"sc_(\d+)")
class BaseShareRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
scopes: Optional[List[str]] = None
@field_validator("scopes")
@classmethod
def _check_scopes_exist(cls, scopes):
if not scopes:
return None
_check_scopes_exist(scopes, who_for="share")
return scopes
class ShareGrantRequest(BaseShareRequest):
"""Validator for requests to grant sharing permission"""
# directly granted shares don't expire
# since Shares are _modifications_ of permissions,
# expiration can get weird
# if it's going to expire, it must expire in
# at least one minute and at most 10 years (avoids nonsense values)
# expires_in: conint(ge=60, le=10 * 525600 * 60) | None = None
user: Optional[str] = None
group: Optional[str] = None
@model_validator(mode='after')
def user_group_exclusive(self):
if self.user and self.group:
raise ValueError("Expected exactly one of `user` or `group`, not both.")
if self.user is None and self.group is None:
raise ValueError("Specify exactly one of `user` or `group`")
return self
class ShareRevokeRequest(ShareGrantRequest):
"""Validator for requests to revoke sharing permission"""
# currently identical to ShareGrantRequest
class ShareCodeGrantRequest(BaseShareRequest):
"""Validator for requests to create sharing codes"""
# must be at least one minute, at most one year, default to one day
expires_in: conint(ge=60, le=525600 * 60) = 86400
class _ShareAPIHandler(APIHandler):
def server_model(self, spawner):
"""Truncated server model for use in shares
- Adds "user" field (just name for now)
- Limits fields to "name", "url", "ready"
from standard server model
"""
user = self.users[spawner.user.id]
if spawner.name in user.spawners:
# use Spawner wrapper if it's active
spawner = user.spawners[spawner.name]
full_model = super().server_model(spawner, user=user)
# filter out subset of fields
server_model = {
"user": {
"name": spawner.user.name,
}
}
# subset keys for sharing
for key in ["name", "url", "ready"]:
if key in full_model:
server_model[key] = full_model[key]
return server_model
def share_model(self, share):
"""Compute the REST API model for a share"""
return {
"server": self.server_model(share.spawner),
"scopes": share.scopes,
"user": {"name": share.user.name} if share.user else None,
"group": {"name": share.group.name} if share.group else None,
"kind": "group" if share.group else "user",
"created_at": isoformat(share.created_at),
}
def share_code_model(self, share_code, code=None):
"""Compute the REST API model for a share code"""
model = {
"server": self.server_model(share_code.spawner),
"scopes": share_code.scopes,
"id": f"sc_{share_code.id}",
"created_at": isoformat(share_code.created_at),
"expires_at": isoformat(share_code.expires_at),
"exchange_count": share_code.exchange_count,
"last_exchanged_at": isoformat(share_code.last_exchanged_at),
}
if code:
model["code"] = code
model["accept_url"] = url_concat(
self.hub.base_url + "accept-share", {"code": code}
)
return model
def _init_share_query(self, kind="share"):
"""Initialize a query for Shares
before applying filters
A method so we can consolidate joins, etc.
"""
if kind == "share":
class_ = orm.Share
elif kind == "code":
class_ = orm.ShareCode
else:
raise ValueError(
f"kind must be `share` or `code`, not {kind!r}"
) # pragma: no cover
query = self.db.query(class_).options(
joinedload(class_.owner).lazyload("*"),
joinedload(class_.spawner).joinedload(orm.Spawner.user).lazyload("*"),
)
if kind == 'share':
query = query.options(
joinedload(class_.user).joinedload(orm.User.groups).lazyload("*"),
joinedload(class_.group).lazyload("*"),
)
return query
def _share_list_model(self, query, kind="share"):
"""Finish a share query, returning the _model_"""
offset, limit = self.get_api_pagination()
if kind == "share":
model_method = self.share_model
elif kind == "code":
model_method = self.share_code_model
else:
raise ValueError(
f"kind must be `share` or `code`, not {kind!r}"
) # pragma: no cover
if kind == "share":
class_ = orm.Share
elif kind == "code":
class_ = orm.ShareCode
total_count = query.count()
query = query.order_by(class_.id.asc()).offset(offset).limit(limit)
share_list = [model_method(share) for share in query if not share.expired]
return self.paginated_model(share_list, offset, limit, total_count)
def _lookup_spawner(self, user_name, server_name, raise_404=True):
"""Lookup orm.Spawner for user_name/server_name
raise 404 if not found
"""
user = self.find_user(user_name)
if user and server_name in user.orm_spawners:
return user.orm_spawners[server_name]
if raise_404:
raise web.HTTPError(404, f"No such server: {user_name}/{server_name}")
else:
return None
class UserShareListAPIHandler(_ShareAPIHandler):
"""List shares a user has access to
includes access granted via group membership
"""
@needs_scope("read:users:shares")
def get(self, user_name):
user = self.find_user(user_name)
if user is None:
raise web.HTTPError(404, f"No such user: {user_name}")
query = self._init_share_query()
filter = orm.Share.user == user
if user.groups:
filter = or_(
orm.Share.user == user,
orm.Share.group_id.in_([group.id for group in user.groups]),
)
query = query.filter(filter)
self.finish(json.dumps(self._share_list_model(query)))
class UserShareAPIHandler(_ShareAPIHandler):
def _lookup_share(self, user_name, owner_name, server_name):
"""Lookup the Share this URL represents
raises 404 if not found
"""
user = self.find_user(user_name)
if user is None:
raise web.HTTPError(
404,
f"No such share for user {user_name} on {owner_name}/{server_name}",
)
spawner = self._lookup_spawner(owner_name, server_name, raise_404=False)
share = None
if spawner:
share = orm.Share.find(self.db, spawner, share_with=user.orm_user)
if share is not None:
return share
else:
raise web.HTTPError(
404,
f"No such share for user {user_name} on {owner_name}/{server_name}",
)
@needs_scope("read:users:shares")
def get(self, user_name, owner_name, _server_name):
share = self._lookup_share(user_name, owner_name, _server_name)
self.finish(json.dumps(self.share_model(share)))
@needs_scope("users:shares")
def delete(self, user_name, owner_name, _server_name):
share = self._lookup_share(user_name, owner_name, _server_name)
self.db.delete(share)
self.db.commit()
self.set_status(204)
class GroupShareListAPIHandler(_ShareAPIHandler, _GroupAPIHandler):
"""List shares granted to a group"""
@needs_scope("read:groups:shares")
def get(self, group_name):
group = self.find_group(group_name)
query = self._init_share_query()
query = query.filter(orm.Share.group == group)
self.finish(json.dumps(self._share_list_model(query)))
class GroupShareAPIHandler(_ShareAPIHandler, _GroupAPIHandler):
"""A single group's access to a single server"""
def _lookup_share(self, group_name, owner_name, server_name):
"""Lookup the Share this URL represents
raises 404 if not found
"""
group = self.find_group(group_name)
spawner = self._lookup_spawner(owner_name, server_name, raise_404=False)
share = None
if spawner:
share = orm.Share.find(self.db, spawner, share_with=group)
if share is not None:
return share
else:
raise web.HTTPError(
404,
f"No such share for group {group_name} on {owner_name}/{server_name}",
)
@needs_scope("read:groups:shares")
def get(self, group_name, owner_name, _server_name):
share = self._lookup_share(group_name, owner_name, _server_name)
self.finish(json.dumps(self.share_model(share)))
@needs_scope("groups:shares")
def delete(self, group_name, owner_name, _server_name):
share = self._lookup_share(group_name, owner_name, _server_name)
self.db.delete(share)
self.db.commit()
self.set_status(204)
class ServerShareAPIHandler(_ShareAPIHandler):
"""Endpoint for shares of a single server
This is where permissions are granted and revoked
"""
@needs_scope("read:shares")
def get(self, user_name, server_name=None):
"""List all shares for a given owner"""
# TODO: optimize this query
# we need Share and only the _names_ of users/groups,
# no any other relationships
query = self._init_share_query()
if server_name is not None:
spawner = self._lookup_spawner(user_name, server_name)
query = query.filter_by(spawner_id=spawner.id)
else:
# lookup owner by id
row = (
self.db.query(orm.User.id)
.where(orm.User.name == user_name)
.one_or_none()
)
if row is None:
raise web.HTTPError(404)
owner_id = row[0]
query = query.filter_by(owner_id=owner_id)
self.finish(json.dumps(self._share_list_model(query)))
@needs_scope('shares')
async def post(self, user_name, server_name=None):
"""POST grants permissions for a given server"""
if server_name is None:
# only GET supported `/shares/{user}` without specified server
raise web.HTTPError(405)
model = self.get_json_body() or {}
try:
request = ShareGrantRequest(**model)
except ValidationError as e:
raise web.HTTPError(400, str(e))
scopes = request.scopes
# check scopes
if not scopes:
# default scopes
scopes = [f"access:servers!server={user_name}/{server_name}"]
# validate that scopes may be granted by requesting user
try:
scopes = orm.Share._apply_filter(frozenset(scopes), user_name, server_name)
except ValueError as e:
raise web.HTTPError(400, str(e))
# resolve target spawner
spawner = self._lookup_spawner(user_name, server_name)
# check permissions
for scope in scopes:
if not self.has_scope(scope):
raise web.HTTPError(
403, f"Do not have permission to grant share with scope {scope}"
)
if request.user:
scope = f"read:users:name!user={request.user}"
if not self.has_scope(scope):
raise web.HTTPError(
403, "Need scope 'read:users:name' to share with users by name"
)
share_with = self.find_user(request.user)
if share_with is None:
raise web.HTTPError(400, f"No such user: {request.user}")
share_with = share_with.orm_user
elif request.group:
if not self.has_scope(f"read:groups:name!group={request.group}"):
raise web.HTTPError(
403, "Need scope 'read:groups:name' to share with groups by name"
)
share_with = orm.Group.find(self.db, name=request.group)
if share_with is None:
raise web.HTTPError(400, f"No such group: {request.group}")
share = orm.Share.grant(self.db, spawner, share_with, scopes=scopes)
self.finish(json.dumps(self.share_model(share)))
@needs_scope('shares')
async def patch(self, user_name, server_name=None):
"""PATCH revokes permissions from single shares for a given server"""
if server_name is None:
# only GET supported `/shares/{user}` without specified server
raise web.HTTPError(405)
model = self.get_json_body() or {}
try:
request = ShareRevokeRequest(**model)
except ValidationError as e:
raise web.HTTPError(400, str(e))
# TODO: check allowed/valid scopes
scopes = request.scopes
# resolve target spawner
spawner = self._lookup_spawner(user_name, server_name)
if request.user:
# don't need to check read:user permissions for revocation
share_with = self.find_user(request.user)
if share_with is None:
# No such user is the same as revoking
self.log.warning(f"No such user: {request.user}")
self.finish("{}")
return
share_with = share_with.orm_user
elif request.group:
share_with = orm.Group.find(self.db, name=request.group)
if share_with is None:
# No such group behaves the same as revoking no permissions
self.log.warning(f"No such group: {request.group}")
self.finish("{}")
return
share = orm.Share.revoke(self.db, spawner, share_with, scopes=scopes)
if share:
self.finish(json.dumps(self.share_model(share)))
else:
# empty dict if share deleted
self.finish("{}")
@needs_scope('shares')
async def delete(self, user_name, server_name=None):
if server_name is None:
# only GET supported `/shares/{user}` without specified server
raise web.HTTPError(405)
spawner = self._lookup_spawner(user_name, server_name)
self.log.info(f"Deleting all shares for {user_name}/{server_name}")
q = self.db.query(orm.Share).filter_by(
spawner_id=spawner.id,
)
res = q.delete()
self.log.info(f"Deleted {res} shares for {user_name}/{server_name}")
self.db.commit()
assert spawner.shares == []
self.set_status(204)
class ServerShareCodeAPIHandler(_ShareAPIHandler):
"""Endpoint for managing sharing codes of a single server
These codes can be exchanged for actual sharing permissions by the recipient.
"""
@needs_scope("read:shares")
def get(self, user_name, server_name=None):
"""List all share codes for a given owner"""
query = self._init_share_query(kind="code")
if server_name is None:
# lookup owner by id
row = (
self.db.query(orm.User.id)
.where(orm.User.name == user_name)
.one_or_none()
)
if row is None:
raise web.HTTPError(404)
owner_id = row[0]
query = query.filter_by(owner_id=owner_id)
else:
spawner = self._lookup_spawner(user_name, server_name)
query = query.filter_by(spawner_id=spawner.id)
self.finish(json.dumps(self._share_list_model(query, kind="code")))
@needs_scope('shares')
async def post(self, user_name, server_name=None):
"""POST creates a new share code"""
if server_name is None:
# only GET supported `/share-codes/{user}` without specified server
raise web.HTTPError(405)
model = self.get_json_body() or {}
try:
request = ShareCodeGrantRequest(**model)
except ValidationError as e:
raise web.HTTPError(400, str(e))
scopes = request.scopes
# check scopes
if not scopes:
# default scopes
scopes = [f"access:servers!server={user_name}/{server_name}"]
try:
scopes = orm.ShareCode._apply_filter(
frozenset(scopes), user_name, server_name
)
except ValueError as e:
raise web.HTTPError(400, str(e))
# validate that scopes may be granted by requesting user
for scope in scopes:
if not self.has_scope(scope):
raise web.HTTPError(
403, f"Do not have permission to grant share with scope {scope}"
)
# resolve target spawner
spawner = self._lookup_spawner(user_name, server_name)
# issue the code
(share_code, code) = orm.ShareCode.new(
self.db, spawner, scopes=scopes, expires_in=request.expires_in
)
# return the model (including code only this one time when it's created)
self.finish(json.dumps(self.share_code_model(share_code, code=code)))
@needs_scope('shares')
def delete(self, user_name, server_name=None):
if server_name is None:
# only GET supported `/share-codes/{user}` without specified server
raise web.HTTPError(405)
code = self.get_argument("code", None)
share_id = self.get_argument("id", None)
spawner = self._lookup_spawner(user_name, server_name)
if code:
# delete one code, identified by the code itself
share_code = orm.ShareCode.find(self.db, code, spawner=spawner)
if share_code is None:
raise web.HTTPError(404, "No matching code found")
else:
self.log.info(f"Deleting share code for {user_name}/{server_name}")
self.db.delete(share_code)
elif share_id:
m = _share_code_id_pat.match(share_id)
four_o_four = f"No code found matching id={share_id}"
if not m:
raise web.HTTPError(404, four_o_four)
share_id = int(m.group(1))
share_code = (
self.db.query(orm.ShareCode)
.filter_by(
spawner_id=spawner.id,
id=share_id,
)
.one_or_none()
)
if share_code is None:
raise web.HTTPError(404, four_o_four)
else:
self.log.info(f"Deleting share code for {user_name}/{server_name}")
self.db.delete(share_code)
else:
self.log.info(f"Deleting all share codes for {user_name}/{server_name}")
deleted = (
self.db.query(orm.ShareCode)
.filter_by(
spawner_id=spawner.id,
)
.delete()
)
self.log.info(
f"Deleted {deleted} share codes for {user_name}/{server_name}"
)
self.db.commit()
self.set_status(204)
default_handlers = [
# TODO: not implementing single all-shared endpoint yet, too hard
# (r"/api/shares", ShareListAPIHandler),
# general management of shares
(r"/api/shares/([^/]+)", ServerShareAPIHandler),
(r"/api/shares/([^/]+)/([^/]*)", ServerShareAPIHandler),
# list shared_with_me for users/groups
(r"/api/users/([^/]+)/shared", UserShareListAPIHandler),
(r"/api/groups/([^/]+)/shared", GroupShareListAPIHandler),
# single-share endpoint (only for easy self-revocation, for now)
(r"/api/users/([^/]+)/shared/([^/]+)/([^/]*)", UserShareAPIHandler),
(r"/api/groups/([^/]+)/shared/([^/]+)/([^/]*)", GroupShareAPIHandler),
# manage sharing codes
(r"/api/share-codes/([^/]+)", ServerShareCodeAPIHandler),
(r"/api/share-codes/([^/]+)/([^/]*)", ServerShareCodeAPIHandler),
]

View File

@@ -2491,7 +2491,7 @@ class JupyterHub(Application):
run periodically
"""
# this should be all the subclasses of Expiring
for cls in (orm.APIToken, orm.OAuthCode):
for cls in (orm.APIToken, orm.OAuthCode, orm.Share, orm.ShareCode):
self.log.debug(f"Purging expired {cls.__name__}s")
cls.purge_expired(self.db)

View File

@@ -491,6 +491,10 @@ class BaseHandler(RequestHandler):
return functools.partial(scopes.check_scope_filter, sub_scope)
def has_scope(self, scope):
"""Is the current request being made with the given scope?"""
return scopes.has_scope(scope, self.parsed_scopes, db=self.db)
@property
def current_user(self):
"""Override .current_user accessor from tornado
@@ -669,15 +673,14 @@ class BaseHandler(RequestHandler):
def authenticate(self, data):
return maybe_future(self.authenticator.get_authenticated_user(self, data))
def get_next_url(self, user=None, default=None):
"""Get the next_url for login redirect
def _validate_next_url(self, next_url):
"""Validate next_url handling
Default URL after login:
protects against external redirects, etc.
- if redirect_to_server (default): send to user's own server
- else: /hub/home
Returns empty string if next_url is not considered safe,
resulting in same behavior as if next_url is not specified.
"""
next_url = self.get_argument('next', default='')
# protect against some browsers' buggy handling of backslash as slash
next_url = next_url.replace('\\', '%5C')
public_url = self.settings.get("public_url")
@@ -723,17 +726,18 @@ class BaseHandler(RequestHandler):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = ''
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
# add /hub/ prefix, to ensure we redirect to the right user's server.
# The next request will be handled by SpawnHandler,
# ultimately redirecting to the logged-in user's server.
without_prefix = next_url[len(self.base_url) :]
next_url = url_path_join(self.hub.base_url, without_prefix)
self.log.warning(
"Redirecting %s to %s. For sharing public links, use /user-redirect/",
self.request.uri,
next_url,
)
return next_url
def get_next_url(self, user=None, default=None):
"""Get the next_url for login redirect
Default URL after login:
- if redirect_to_server (default): send to user's own server
- else: /hub/home
"""
next_url = self.get_argument('next', default='')
next_url = self._validate_next_url(next_url)
# this is where we know if next_url is coming from ?next= param or we are using a default url
if next_url:

View File

@@ -12,9 +12,9 @@ from jinja2 import TemplateNotFound
from tornado import web
from tornado.httputil import url_concat
from .. import __version__
from .. import __version__, orm
from ..metrics import SERVER_POLL_DURATION_SECONDS, ServerPollStatus
from ..scopes import needs_scope
from ..scopes import describe_raw_scopes, needs_scope
from ..utils import maybe_future, url_escape_path, url_path_join, utcnow
from .base import BaseHandler
@@ -553,6 +553,84 @@ class TokenPageHandler(BaseHandler):
self.finish(html)
class AcceptShareHandler(BaseHandler):
def _get_next_url(self, owner, spawner):
"""Get next_url for a given owner/spawner"""
next_url = self.get_argument("next", "")
next_url = self._validate_next_url(next_url)
if next_url:
return next_url
# default behavior:
# if it's active, redirect to server URL
if spawner.name in owner.spawners:
spawner = owner.spawners[spawner.name]
if spawner.active:
# redirect to spawner url
next_url = owner.server_url(spawner.name)
if not next_url:
# spawner not active
# TODO: next_url not specified and not running, what do we do?
# for now, redirect as if it's running,
# but that's very likely to fail on "You can't launch this server"
# is there a better experience for this?
next_url = owner.server_url(spawner.name)
# validate again, which strips away host to just absolute path
return self._validate_next_url(next_url)
@web.authenticated
async def get(self):
code = self.get_argument("code")
share_code = orm.ShareCode.find(self.db, code=code)
if share_code is None:
raise web.HTTPError(404, "Share not found or expired")
if share_code.owner == self.current_user.orm_user:
raise web.HTTPError(403, "You can't share with yourself!")
scope_descriptions = describe_raw_scopes(
share_code.scopes,
username=self.current_user.name,
)
owner = self._user_from_orm(share_code.owner)
spawner = share_code.spawner
if spawner.name in owner.spawners:
spawner = owner.spawners[spawner.name]
spawner_ready = spawner.ready
else:
spawner_ready = False
html = await self.render_template(
'accept-share.html',
code=code,
owner=owner,
spawner=spawner,
spawner_ready=spawner_ready,
spawner_url=owner.server_url(spawner.name),
scope_descriptions=scope_descriptions,
next_url=self._get_next_url(owner, spawner),
)
self.finish(html)
@web.authenticated
def post(self):
code = self.get_argument("code")
self.log.debug("Looking up %s", code)
share_code = orm.ShareCode.find(self.db, code=code)
if share_code is None:
raise web.HTTPError(400, f"Invalid share code: {code}")
if share_code.owner == self.current_user.orm_user:
raise web.HTTPError(400, "You can't share with yourself!")
user = self.current_user
share = share_code.exchange(user.orm_user)
owner = self._user_from_orm(share.owner)
spawner = share.spawner
next_url = self._get_next_url(owner, spawner)
self.redirect(next_url)
class ProxyErrorHandler(BaseHandler):
"""Handler for rendering proxy error pages"""
@@ -609,6 +687,7 @@ default_handlers = [
(r'/spawn/([^/]+)', SpawnHandler),
(r'/spawn/([^/]+)/([^/]+)', SpawnHandler),
(r'/token', TokenPageHandler),
(r'/accept-share', AcceptShareHandler),
(r'/error/(\d+)', ProxyErrorHandler),
(r'/health$', HealthCheckHandler),
(r'/api/health$', HealthCheckHandler),

View File

@@ -5,9 +5,11 @@
import enum
import json
import numbers
import secrets
from base64 import decodebytes, encodebytes
from datetime import timedelta
from functools import partial
from functools import lru_cache, partial
from itertools import chain
import alembic.command
import alembic.config
@@ -33,6 +35,7 @@ from sqlalchemy import (
from sqlalchemy.orm import (
Session,
declarative_base,
declared_attr,
interfaces,
joinedload,
object_session,
@@ -94,6 +97,10 @@ class JSONDict(TypeDecorator):
class JSONList(JSONDict):
"""Represents an immutable structure as a json-encoded string (to be used for list type columns).
Accepts list, tuple, sets for assignment
Always read as a list
Usage::
JSONList(JSONDict)
@@ -101,8 +108,12 @@ class JSONList(JSONDict):
"""
def process_bind_param(self, value, dialect):
if isinstance(value, list) and value is not None:
if isinstance(value, (list, tuple)):
value = json.dumps(value)
if isinstance(value, set):
# serialize sets as ordered lists
value = json.dumps(sorted(value))
return value
def process_result_value(self, value, dialect):
@@ -226,6 +237,15 @@ class Group(Base):
roles = relationship(
'Role', secondary='group_role_map', back_populates='groups', lazy="selectin"
)
shared_with_me = relationship(
"Share",
back_populates="group",
cascade="all, delete-orphan",
lazy="selectin",
)
# used in some model fields to differentiate 'whoami'
kind = "group"
def __repr__(self):
return f"<{self.__class__.__name__} {self.name}>"
@@ -296,6 +316,42 @@ class User(Base):
oauth_codes = relationship(
"OAuthCode", back_populates="user", cascade="all, delete-orphan"
)
# sharing relationships
shares = relationship(
"Share",
back_populates="owner",
cascade="all, delete-orphan",
foreign_keys="Share.owner_id",
)
share_codes = relationship(
"ShareCode",
back_populates="owner",
cascade="all, delete-orphan",
foreign_keys="ShareCode.owner_id",
)
shared_with_me = relationship(
"Share",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="Share.user_id",
lazy="selectin",
)
@property
def all_shared_with_me(self):
"""return all shares shared with me,
including via group
"""
return list(
chain(
self.shared_with_me,
*[group.shared_with_me for group in self.groups],
)
)
cookie_id = Column(Unicode(255), default=new_token, nullable=False, unique=True)
# User.state is actually Spawner state
# We will need to figure something else out if/when we have multiple spawners per user
@@ -304,6 +360,10 @@ class User(Base):
# Encryption is handled elsewhere
encrypted_auth_state = Column(LargeBinary)
# used in some model fields to differentiate whether an owner or actor
# is a user or service
kind = "user"
def __repr__(self):
return "<{cls}({name} {running}/{total} running)>".format(
cls=self.__class__.__name__,
@@ -345,6 +405,13 @@ class Spawner(Base):
cascade="all, delete-orphan",
)
shares = relationship(
"Share", back_populates="spawner", cascade="all, delete-orphan"
)
share_codes = relationship(
"ShareCode", back_populates="spawner", cascade="all, delete-orphan"
)
state = Column(JSONDict)
name = Column(Unicode(255))
@@ -457,6 +524,9 @@ class Service(Base):
single_parent=True,
)
# used in some model fields to differentiate 'whoami'
kind = "service"
def new_api_token(self, token=None, **kwargs):
"""Create a new API token
If `token` is given, load that token.
@@ -496,6 +566,14 @@ class Expiring:
else:
return None
@property
def expired(self):
"""Is this object expired?"""
if not self.expires_at:
return False
else:
return self.expires_in <= 0
@classmethod
def purge_expired(cls, db):
"""Purge expired API Tokens from the database"""
@@ -528,7 +606,7 @@ class Hashed(Expiring):
@property
def token(self):
raise AttributeError("token is write-only")
raise AttributeError(f"{self.__class__.__name__}.token is write-only")
@token.setter
def token(self, token):
@@ -556,12 +634,13 @@ class Hashed(Expiring):
"""Check if a token is acceptable"""
if len(token) < cls.min_length:
raise ValueError(
"Tokens must be at least %i characters, got %r"
% (cls.min_length, token)
f"{cls.__name__}.token must be at least {cls.min_length} characters, got {len(token)}: {token[: cls.prefix_length]}..."
)
found = cls.find(db, token)
if found:
raise ValueError("Collision on token: %s..." % token[: cls.prefix_length])
raise ValueError(
f"Collision on {cls.__name__}: {token[: cls.prefix_length]}..."
)
@classmethod
def find_prefix(cls, db, token):
@@ -600,6 +679,339 @@ class Hashed(Expiring):
return orm_token
class _Share:
"""Common columns for Share and ShareCode"""
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime, nullable=False, default=utcnow)
# TODO: owner_id and spawner_id columns don't need `@declared_attr` when we can require sqlalchemy 2
# the owner of the shared server
# this is redundant with spawner.user, but saves a join
@declared_attr
def owner_id(self):
return Column(Integer, ForeignKey('users.id', ondelete="CASCADE"))
@declared_attr
def owner(self):
# table name happens to be appropriate 'shares', 'share_codes'
# could be another, more explicit attribute, but the values would be the same
return relationship(
"User",
back_populates=self.__tablename__,
foreign_keys=[self.owner_id],
lazy="selectin",
)
# the spawner the share is for
@declared_attr
def spawner_id(self):
return Column(Integer, ForeignKey('spawners.id', ondelete="CASCADE"))
@declared_attr
def spawner(self):
return relationship(
"Spawner",
back_populates=self.__tablename__,
lazy="selectin",
)
# the permissions granted (!server filter will always be applied)
scopes = Column(JSONList)
expires_at = Column(DateTime, nullable=True)
@classmethod
def apply_filter(cls, scopes, spawner):
"""Apply our filter, ensures all scopes have appropriate !server filter
Any other filters will raise ValueError.
"""
return cls._apply_filter(frozenset(scopes), spawner.user.name, spawner.name)
@staticmethod
@lru_cache()
def _apply_filter(scopes, owner_name, server_name):
"""
implementation of Share.apply_filter
Static method so @lru_cache is persisted across instances
"""
filtered_scopes = []
server_filter = f"server={owner_name}/{server_name}"
for scope in scopes:
base_scope, _, filter = scope.partition("!")
if filter and filter != server_filter:
raise ValueError(
f"!{filter} not allowed on sharing {scope}, only !{server_filter}"
)
filtered_scopes.append(f"{base_scope}!{server_filter}")
return frozenset(filtered_scopes)
class Share(_Share, Expiring, Base):
"""A single record of a sharing permission
granted by one user to another user (or group)
Restricted to a single server.
"""
__tablename__ = "shares"
# who the share is granted to (user or group)
user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=True)
user = relationship(
"User", back_populates="shared_with_me", foreign_keys=[user_id], lazy="selectin"
)
group_id = Column(
Integer, ForeignKey('groups.id', ondelete="CASCADE"), nullable=True
)
group = relationship("Group", back_populates="shared_with_me", lazy="selectin")
def __repr__(self):
if self.user:
kind = "user"
name = self.user.name
elif self.group:
kind = "group"
name = self.group.name
else: # pragma: no cover
kind = "deleted"
name = "unknown"
if self.owner and self.spawner:
server_name = f"{self.owner.name}/{self.spawner.name}"
else: # pragma: n cover
server_name = "unknown/deleted"
return f"<{self.__class__.__name__}(server={server_name}, scopes={self.scopes}, {kind}={name})>"
@staticmethod
def _share_with_key(share_with):
"""Get the field name for share with
either group_id or user_id, depending on type of share_with
raises TypeError if neither User nor Group
"""
if isinstance(share_with, User):
return "user_id"
elif isinstance(share_with, Group):
return "group_id"
else:
raise TypeError(
f"Can only share with orm.User or orm.Group, not {share_with!r}"
)
@classmethod
def find(cls, db, spawner, share_with):
"""Find an existing
Shares are unique for a given (spawner, user)
"""
filter_by = {
cls._share_with_key(share_with): share_with.id,
"spawner_id": spawner.id,
"owner_id": spawner.user.id,
}
return db.query(Share).filter_by(**filter_by).one_or_none()
@staticmethod
def _get_log_name(spawner, share_with):
"""construct log snippet to refer to the share"""
return (
f"{share_with.kind}:{share_with.name} on {spawner.user.name}/{spawner.name}"
)
@property
def _log_name(self):
return self._get_log_name(self.spawner, self.user or self.group)
@classmethod
def grant(cls, db, spawner, share_with, scopes=None):
"""Grant shared permissions for a server
Updates existing Share if there is one,
otherwise creates a new Share
"""
if scopes is None:
scopes = frozenset(
[f"access:servers!server={spawner.user.name}/{spawner.name}"]
)
scopes = cls._apply_filter(frozenset(scopes), spawner.user.name, spawner.name)
if not scopes:
raise ValueError("Must specify scopes to grant.")
# 1. lookup existing share and update
share = cls.find(db, spawner, share_with)
share_with_log = cls._get_log_name(spawner, share_with)
if share is not None:
# update existing permissions in-place
# extend permissions
existing_scopes = set(share.scopes)
added_scopes = set(scopes).difference(existing_scopes)
if not added_scopes:
app_log.info(f"No new scopes for {share_with_log}")
return share
new_scopes = sorted(existing_scopes | added_scopes)
app_log.info(f"Granting scopes {sorted(added_scopes)} for {share_with_log}")
share.scopes = new_scopes
db.commit()
else:
# no share for (spawner, share_with), create a new one
app_log.info(f"Sharing scopes {sorted(scopes)} for {share_with_log}")
share = cls(
created_at=cls.now(),
# copy shared fields
owner=spawner.user,
spawner=spawner,
scopes=sorted(scopes),
)
if share_with.kind == "user":
share.user = share_with
elif share_with.kind == "group":
share.group = share_with
else:
raise TypeError(f"Expected user or group, got {share_with!r}")
db.add(share)
db.commit()
return share
@classmethod
def revoke(cls, db, spawner, share_with, scopes=None):
"""Revoke permissions for share_with on `spawner`
If scopes are not specified, all scopes are revoked
"""
share = cls.find(db, spawner, share_with)
if share is None:
_log_name = cls._get_log_name(spawner, share_with)
app_log.info(f"No permissions to revoke from {_log_name}")
return
else:
_log_name = share._log_name
if scopes is None:
app_log.info(f"Revoked all permissions from {_log_name}")
db.delete(share)
db.commit()
return None
# update scopes
new_scopes = [scope for scope in share.scopes if scope not in scopes]
revoked_scopes = [scope for scope in scopes if scope in set(share.scopes)]
if new_scopes == share.scopes:
app_log.info(f"No change in scopes for {_log_name}")
return share
elif not new_scopes:
# revoked all scopes, delete the Share
app_log.info(f"Revoked all permissions from {_log_name}")
db.delete(share)
db.commit()
else:
app_log.info(f"Revoked {revoked_scopes} from {_log_name}")
share.scopes = new_scopes
db.commit()
if new_scopes:
return share
else:
return None
class ShareCode(_Share, Hashed, Base):
"""A code that can be exchanged for a Share
Ultimately, the same as a Share, but has a 'code'
instead of a user or group that it is shared with.
The code can be exchanged to create or update an actual Share.
"""
__tablename__ = "share_codes"
hashed = Column(Unicode(255), unique=True)
prefix = Column(Unicode(16), index=True)
exchange_count = Column(Integer, default=0)
last_exchanged_at = Column(DateTime, nullable=True, default=None)
_code_bytes = 32
default_expires_in = 86400
def __repr__(self):
if self.owner and self.spawner:
server_name = f"{self.owner.name}/{self.spawner.name}"
else:
server_name = "unknown/deleted"
return f"<{self.__class__.__name__}(server={server_name}, scopes={self.scopes}, expires_at={self.expires_at})>"
@classmethod
def new(
cls,
db,
spawner,
*,
scopes,
expires_in=None,
**kwargs,
):
"""Create a new ShareCode"""
app_log.info(f"Creating share code for {spawner.user.name}/{spawner.name}")
# verify scopes have the necessary filter
kwargs["scopes"] = sorted(cls.apply_filter(scopes, spawner))
if not expires_in:
expires_in = cls.default_expires_in
kwargs["expires_at"] = utcnow() + timedelta(seconds=expires_in)
kwargs["spawner"] = spawner
kwargs["owner"] = spawner.user
code = secrets.token_urlsafe(cls._code_bytes)
# create the ShareCode
share_code = cls(**kwargs)
# setting Hashed.token property sets the `hashed` column in the db
share_code.token = code
# actually put it in the db
db.add(share_code)
db.commit()
return (share_code, code)
@classmethod
def find(cls, db, code, *, spawner=None):
"""Lookup a single ShareCode by code"""
prefix_match = cls.find_prefix(db, code)
if spawner:
prefix_match = prefix_match.filter_by(spawner_id=spawner.id)
for share_code in prefix_match:
if share_code.match(code):
return share_code
def exchange(self, share_with):
"""exchange a ShareCode for a Share
share_with can be a User or a Group.
"""
db = inspect(self).session
share_code_log = f"Share code {self.prefix}..."
if self.expired:
db.delete(self)
db.commit()
raise ValueError(f"{share_code_log} expired")
share_with_log = f"{share_with.kind}:{share_with.name} on {self.owner.name}/{self.spawner.name}"
app_log.info(f"Exchanging {share_code_log} for {share_with_log}")
share = Share.grant(db, self.spawner, share_with, self.scopes)
# note: we count exchanges, even if they don't modify the permissions
# (e.g. one user exchanging the same code twice)
self.exchange_count += 1
self.last_exchanged_at = self.now()
db.commit()
return share
# ------------------------------------
# OAuth tables
# ------------------------------------

View File

@@ -47,6 +47,7 @@ def get_default_roles():
'access:servers',
'read:roles',
'read:metrics',
'shares',
],
},
{

View File

@@ -144,6 +144,36 @@ scope_definitions = {
'access:services': {
'description': 'Access services via API or browser.',
},
'users:shares': {
'description': "Read and revoke a user's access to shared servers.",
'subscopes': [
'read:users:shares',
],
},
'read:users:shares': {
'description': "Read servers shared with a user.",
},
'groups:shares': {
'description': "Read and revoke a group's access to shared servers.",
'subscopes': [
'read:groups:shares',
],
},
'read:groups:shares': {
'description': "Read servers shared with a group.",
},
'read:shares': {
'description': "Read information about shared access to servers.",
},
'shares': {
'description': "Manage access to shared servers.",
'subscopes': [
'access:servers',
'read:shares',
'users:shares',
'groups:shares',
],
},
'proxy': {
'description': 'Read information about the proxys routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
},
@@ -178,7 +208,6 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None):
Otherwise, it can result in lower than intended permissions,
(i.e. users!group=x & users!user=y will be empty, even if user y is in group x.)
"""
empty_set = frozenset()
scopes_a = frozenset(scopes_a)
scopes_b = frozenset(scopes_b)
@@ -189,11 +218,12 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None):
# if we need a group lookup, the result is not cacheable
nonlocal needs_db
needs_db = True
user = db.query(orm.User).filter_by(name=username).first()
if user is None:
return empty_set
else:
return {group.name for group in user.groups}
group_query = (
db.query(orm.Group.name)
.join(orm.User.groups)
.filter(orm.User.name == username)
)
return {row[0] for row in group_query}
@lru_cache()
def groups_for_server(server):
@@ -348,6 +378,9 @@ def get_scopes_for(orm_object):
owner = orm_object.user or orm_object.service
owner_roles = roles.get_roles_for(owner)
owner_scopes = roles.roles_to_expanded_scopes(owner_roles, owner)
if owner is orm_object.user:
for share in owner.shared_with_me:
owner_scopes |= frozenset(share.scopes)
token_scopes = set(orm_object.scopes)
if 'inherit' in token_scopes:
@@ -401,12 +434,28 @@ def get_scopes_for(orm_object):
roles.get_roles_for(orm_object),
owner=orm_object,
)
if isinstance(orm_object, (orm.User, orm.Service)):
owner = orm_object
# add permissions granted from 'shares'
if hasattr(orm_object, "shared_with_me"):
for share in orm_object.shared_with_me:
expanded_scopes |= expand_share_scopes(share)
if isinstance(orm_object, orm.User):
for group in orm_object.groups:
for share in group.shared_with_me:
expanded_scopes |= expand_share_scopes(share)
return expanded_scopes
def expand_share_scopes(share):
"""Get expanded scopes for a Share"""
return expand_scopes(
share.scopes,
owner=share.user or share.group,
oauth_client=share.spawner.oauth_client,
)
@lru_cache()
def _expand_self_scope(username):
"""
@@ -431,6 +480,9 @@ def _expand_self_scope(username):
'read:users',
'read:users:name',
'read:users:groups',
'users:shares',
'read:users:shares',
'read:shares',
'users:activity',
'read:users:activity',
'servers',
@@ -647,68 +699,112 @@ def _resolve_requested_scopes(requested_scopes, have_scopes, user, client, db):
return (allowed_scopes, disallowed_scopes)
def _needs_scope_expansion(filter_, filter_value, sub_scope):
def _needs_group_expansion(filter_, filter_value, sub_scope):
"""
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
Assumptions:
filter_ != Scope.ALL
"""
if not (filter_ == 'user' and 'group' in sub_scope):
if not (filter_ in {'user', 'server'} and 'group' in sub_scope):
return False
if 'user' in sub_scope:
return filter_value not in sub_scope['user']
if filter_ in sub_scope:
return filter_value not in sub_scope[filter_]
else:
return True
def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
"""Check if username is present in set of allowed groups"""
user = handler.find_user(user_name)
if user is None:
raise web.HTTPError(404, "No access to resources or resources not found")
group_names = {group.name for group in user.groups}
return bool(set(scope_group_names) & group_names)
def _has_scope_key(scope, have_scopes, *, post_filter=False, db=None):
"""Cache key for has_scope"""
if isinstance(have_scopes, dict):
have_scopes = FrozenDict(have_scopes)
else:
have_scopes = frozenset(have_scopes)
return (scope, have_scopes, post_filter)
def _check_scope_access(api_handler, req_scope, **kwargs):
"""Check if scopes satisfy requirements
Returns True for (potentially restricted) access, False for refused access
@lru_cache_key(_has_scope_key)
def has_scope(scope, have_scopes, *, post_filter=False, db=None):
"""Boolean function for whether we have a given scope
Args:
scope (str): a single scope
have_scopes: parsed_scopes dict or expanded_scopes set
post_filter (bool):
Allows returning true if _some_ access is granted,
if not full access.
Only allowed if scope has no filter
db (optional): the db session
Required to check group membership,
unused otherwise
Returns:
True if access is allowed, False otherwise.
If post_filer is True and have_scopes contains _filtered_ access,
will return True, assuming filtered-access will be handled later
(e.g. in the listing-users handler)
"""
# Parse user name and server name together
try:
api_name = api_handler.request.path
except AttributeError:
api_name = type(api_handler).__name__
if 'user' in kwargs and 'server' in kwargs:
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
if req_scope not in api_handler.parsed_scopes:
app_log.debug("No access to %s via %s", api_name, req_scope)
return False
if api_handler.parsed_scopes[req_scope] == Scope.ALL:
app_log.debug("Unrestricted access to %s via %s", api_name, req_scope)
return True
# Apply filters
sub_scope = api_handler.parsed_scopes[req_scope]
if not kwargs:
app_log.debug(
"Client has restricted access to %s via %s. Internal filtering may apply",
api_name,
req_scope,
req_scope, _, full_filter = scope.partition("!")
filter_, _, filter_value = full_filter.partition("=")
if filter_ and not filter_value:
raise ValueError(
f"Unexpanded scope filter {scope} not allowed. Use expanded scopes."
)
if isinstance(have_scopes, dict):
parsed_scopes = have_scopes
else:
parsed_scopes = parse_scopes(have_scopes)
if req_scope not in parsed_scopes:
return False
have_scope_filters = parsed_scopes[req_scope]
if have_scope_filters == Scope.ALL:
# access to all resources
return True
for filter_, filter_value in kwargs.items():
if filter_ in sub_scope and filter_value in sub_scope[filter_]:
app_log.debug("Argument-based access to %s via %s", api_name, req_scope)
if not filter_:
if post_filter:
# allow filtering after the fact
return True
if _needs_scope_expansion(filter_, filter_value, sub_scope):
group_names = sub_scope['group']
if _check_user_in_expanded_scope(api_handler, filter_value, group_names):
app_log.debug("Restricted client access supported with group expansion")
return True
app_log.debug(
"Client access refused; filters do not match API endpoint %s request" % api_name
)
raise web.HTTPError(404, "No access to resources or resources not found")
else:
return False
if post_filter:
raise ValueError("post_filter=True only allowed for unfiltered scopes")
_db_used = False
if filter_ in have_scope_filters and filter_value in have_scope_filters[filter_]:
return True
# server->user
if filter_ == "server" and "user" in have_scope_filters:
user_name = filter_value.partition("/")[0]
if user_name in have_scope_filters["user"]:
return True
if db and _needs_group_expansion(filter_, filter_value, have_scope_filters):
_db_used = True
if filter_ == "user":
user_name = filter_value
elif filter_ == "server":
user_name = filter_value.partition("/")[0]
else:
raise ValueError(
f"filter_ should be 'user' or 'server' here, not {filter_!r}"
)
group_names = have_scope_filters['group']
have_group_query = (
db.query(orm.Group.name)
.join(orm.User.groups)
.filter(orm.User.name == user_name)
.filter(orm.Group.name.in_(group_names))
)
if have_group_query.count() > 0:
return DoNotCache(True)
if _db_used:
return DoNotCache(False)
else:
return False
def _check_scopes_exist(scopes, who_for=None):
@@ -813,6 +909,8 @@ def parse_scopes(scope_list):
if parsed_scopes[base_scope] != Scope.ALL:
key, _, value = filter_.partition('=')
if not value:
raise ValueError(f"Empty string is not a valid filter: {scope}")
if key not in parsed_scopes[base_scope]:
parsed_scopes[base_scope][key] = {value}
else:
@@ -884,21 +982,53 @@ def needs_scope(*scopes):
if resource_name in bound_sig.arguments:
resource_value = bound_sig.arguments[resource_name]
s_kwargs[resource] = resource_value
if "server" in s_kwargs:
# merge user_name, server_name into server=user/server
if "user" not in s_kwargs:
raise ValueError(
"Cannot filter on 'server_name' without 'user_name'"
)
s_kwargs["server"] = f"{s_kwargs['user']}/{s_kwargs['server']}"
s_kwargs.pop("user")
if len(s_kwargs) > 1:
raise ValueError(
f"Cannot filter on more than one field, got {s_kwargs}"
)
elif s_kwargs:
filter_, filter_value = next(iter(s_kwargs.items()))
else:
filter_ = filter_value = None
for scope in scopes:
if filter_ is not None:
scope = f"{scope}!{filter_}={filter_value}"
app_log.debug("Checking access to %s via scope %s", end_point, scope)
has_access = _check_scope_access(self, scope, **s_kwargs)
has_access = has_scope(
scope,
self.parsed_scopes,
post_filter=filter_ is None,
db=self.db,
)
if has_access:
return func(self, *args, **kwargs)
app_log.warning(
"Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)
)
"Not authorizing access to %s. Requires any of [%s] on %s, not derived from scopes [%s]",
end_point,
", ".join(scopes),
"*" if filter_ is None else f"{filter_}={filter_value}",
", ".join(self.expanded_scopes),
)
if filter_ and any(scope in self.parsed_scopes for scope in scopes):
# not allowed due do filtered access,
# same error for nonexistence as missing permission
raise web.HTTPError(
404, "No access to resources or resources not found"
)
raise web.HTTPError(
403,
"Action is not authorized with current scopes; requires any of [{}]".format(
", ".join(scopes)
),
"Action is not authorized with current scopes;"
f" requires any of [{', '.join(scopes)}]",
)
return _auth_func
@@ -939,17 +1069,28 @@ def identify_scopes(obj=None):
raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}")
@lru_cache_key(lambda oauth_client: oauth_client.identifier)
def access_scopes(oauth_client):
def _access_cache_key(oauth_client=None, *, spawner=None, service=None):
if oauth_client:
return ("oauth", oauth_client.identifier)
elif spawner:
return ("spawner", spawner.user.name, spawner.name)
elif service:
return ("service", service.name)
@lru_cache_key(_access_cache_key)
def access_scopes(oauth_client=None, *, spawner=None, service=None):
"""Return scope(s) required to access an oauth client"""
scopes = set()
if oauth_client.identifier == "jupyterhub":
if oauth_client and oauth_client.identifier == "jupyterhub":
return frozenset()
spawner = oauth_client.spawner
if spawner is None and oauth_client:
spawner = oauth_client.spawner
if spawner:
scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
else:
service = oauth_client.service
if service is None:
service = oauth_client.service
if service:
scopes.add(f"access:services!service={service.name}")
else:
@@ -1009,7 +1150,6 @@ def describe_parsed_scopes(parsed_scopes, username=None):
"""
descriptions = []
for scope, filters in parsed_scopes.items():
base_text = scope_definitions[scope]["description"]
if filters == Scope.ALL:
# no filter
filter_text = ""
@@ -1019,9 +1159,6 @@ def describe_parsed_scopes(parsed_scopes, username=None):
if kind == 'user' and names == {username}:
filter_chunks.append("only you")
else:
kind_text = kind
if kind == 'group':
kind_text = "users in group"
if len(names) == 1:
filter_chunks.append(f"{kind}: {list(names)[0]}")
else:
@@ -1046,7 +1183,6 @@ def describe_raw_scopes(raw_scopes, username=None):
descriptions = []
for raw_scope in raw_scopes:
scope, _, filter_ = raw_scope.partition("!")
base_text = scope_definitions[scope]["description"]
if not filter_:
# no filter
filter_text = ""

View File

@@ -225,6 +225,7 @@ class JupyterHubIdentityProvider(IdentityProvider):
user = handler.current_user
# originally implemented in jupyterlab's LabApp
page_config["hubUser"] = user.name if user else ""
page_config["hubServerUser"] = os.environ.get("JUPYTERHUB_USER", "")
page_config["hubPrefix"] = hub_prefix = self.hub_auth.hub_prefix
page_config["hubHost"] = self.hub_auth.hub_host
page_config["shareUrl"] = url_path_join(hub_prefix, "user-redirect")

View File

@@ -713,6 +713,7 @@ class SingleUserNotebookAppMixin(Configurable):
Only has effect on jupyterlab_server >=2.9
"""
page_config["token"] = self.hub_auth.get_token(handler) or ""
page_config["hubServerUser"] = os.environ.get("JUPYTERHUB_USER", "")
return page_config
def patch_default_headers(self):

View File

@@ -17,8 +17,10 @@ from jupyterhub.utils import url_escape_path, url_path_join
pytestmark = pytest.mark.browser
async def login(browser, username, password):
"""filling the login form by user and pass_w parameters and iniate the login"""
async def login(browser, username, password=None):
"""filling the login form by user and pass_w parameters and initiate the login"""
if password is None:
password = username
await browser.get_by_label("Username:").click()
await browser.get_by_label("Username:").fill(username)

View File

@@ -0,0 +1,64 @@
import re
import pytest
from playwright.async_api import expect
from tornado.httputil import url_concat
from jupyterhub.utils import url_path_join
from ..conftest import new_username
from ..utils import add_user, api_request, public_host
from .test_browser import login
pytestmark = pytest.mark.browser
async def test_share_code_flow_full(app, browser, full_spawn, create_user_with_scopes):
share_user = add_user(app.db, name=new_username("share_with"))
user = create_user_with_scopes(
"shares!user", "self", f"read:users:name!user={share_user.name}"
)
# start server
await user.spawn("")
await app.proxy.add_user(user)
spawner = user.spawner
# issue_code
share_url = f"share-codes/{user.name}/{spawner.name}"
r = await api_request(
app,
share_url,
method="post",
name=user.name,
)
r.raise_for_status()
share_model = r.json()
print(share_model)
assert "code" in share_model
code = share_model["code"]
# visit share page
accept_share_url = url_path_join(public_host(app), app.hub.base_url, "accept-share")
accept_share_url = url_concat(accept_share_url, {"code": code})
await browser.goto(accept_share_url)
# wait for login
await expect(browser).to_have_url(re.compile(r".*/login"))
# login
await login(browser, share_user.name)
# back to accept-share page
await expect(browser).to_have_url(re.compile(r".*/accept-share"))
header_text = await browser.locator("//h2").first.text_content()
assert f"access {user.name}'s server" in header_text
assert f"You ({share_user.name})" in header_text
# TODO verify form
submit = browser.locator('//input[@type="submit"]')
await submit.click()
# redirects to server, which triggers oauth approval
await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize"))
submit = browser.locator('//input[@type="submit"]')
await submit.click()
# finally, we are at the server!
await expect(browser).to_have_url(re.compile(f".*/user/{user.name}/.*"))

View File

@@ -30,7 +30,6 @@ import asyncio
import copy
import os
import sys
from getpass import getuser
from subprocess import TimeoutExpired
from unittest import mock
@@ -42,7 +41,13 @@ from tornado.platform.asyncio import AsyncIOMainLoop
import jupyterhub.services.service
from .. import crypto, orm, scopes
from ..roles import create_role, get_default_roles, mock_roles, update_roles
from ..roles import (
assign_default_roles,
create_role,
get_default_roles,
mock_roles,
update_roles,
)
from ..utils import random_port
from . import mocking
from .mocking import MockHub
@@ -104,18 +109,18 @@ def auth_state_enabled(app):
@fixture
def db():
"""Get a db session"""
global _db
if _db is None:
# make sure some initial db contents are filled out
# specifically, the 'default' jupyterhub oauth client
app = MockHub(db_url='sqlite:///:memory:')
app.init_db()
_db = app.db
for role in get_default_roles():
create_role(_db, role)
user = orm.User(name=getuser())
_db.add(user)
_db.commit()
# make sure some initial db contents are filled out
# specifically, the 'default' jupyterhub oauth client
app = MockHub(db_url='sqlite:///:memory:')
app.init_db()
_db = app.db
for role in get_default_roles():
create_role(_db, role)
user = orm.User(name="user")
_db.add(user)
_db.commit()
assign_default_roles(_db, user)
_db.commit()
return _db
@@ -181,10 +186,16 @@ async def cleanup_after(request, io_loop):
print(f"Stopping leftover server {spawner._log_name}")
await user.stop(name)
if user.name not in {'admin', 'user'}:
app.log.debug(f"Deleting test user {user.name}")
app.users.delete(user.id)
# delete groups
for group in app.db.query(orm.Group):
app.log.debug(f"Deleting test group {group.name}")
app.db.delete(group)
# delete shares
for share in app.db.query(orm.Share):
app.log.debug(f"Deleting test share {share}")
app.db.delete(share)
# clear services
for name, service in app._service_map.items():

View File

@@ -77,6 +77,7 @@ async def test_default_server(app, named_servers):
'server': user.url,
'servers': {
'': {
'full_name': f"{username}/",
'name': '',
'started': TIMESTAMP,
'last_activity': TIMESTAMP,
@@ -165,6 +166,7 @@ async def test_create_named_server(
'auth_state': None,
'servers': {
servername: {
'full_name': f"{username}/{servername}",
'name': name,
'started': TIMESTAMP,
'last_activity': TIMESTAMP,

View File

@@ -185,7 +185,9 @@ def test_service_server(db):
def test_token_find(db):
service = db.query(orm.Service).first()
service = orm.Service(name='sample')
db.add(service)
db.commit()
user = db.query(orm.User).first()
service_token = service.new_api_token()
user_token = user.new_api_token()
@@ -238,7 +240,7 @@ async def test_spawn_fails(db):
def test_groups(db):
user = orm.User.find(db, name='aeofel')
user = orm.User(name='aeofel')
db.add(user)
group = orm.Group(name='lives')
@@ -496,6 +498,71 @@ def test_group_delete_cascade(db):
assert user1 not in group1.users
def test_share_user(db):
user1 = orm.User(name='user1')
user2 = orm.User(name='user2')
spawner = orm.Spawner(user=user1)
db.add(user1)
db.add(user2)
db.add(spawner)
db.commit()
share = orm.Share(
owner=user1,
spawner=spawner,
user=user2,
)
db.add(share)
db.commit()
assert user1.shares == [share]
assert spawner.shares == [share]
assert user1.shared_with_me == []
assert user2.shared_with_me == [share]
db.delete(share)
db.commit()
assert user1.shares == []
assert spawner.shares == []
assert user1.shared_with_me == []
assert user2.shared_with_me == []
def test_share_group(db):
initial_list = list(db.query(orm.User))
assert len(initial_list) <= 1
user1 = orm.User(name='user1')
user2 = orm.User(name='user2')
group2 = orm.Group(name='group2')
spawner = orm.Spawner(user=user1)
db.add(user1)
db.add(user2)
db.add(group2)
db.add(spawner)
db.commit()
group2.users.append(user2)
db.commit()
share = orm.Share(
owner=user1,
spawner=spawner,
group=group2,
)
db.add(share)
db.commit()
assert user1.shares == [share]
assert spawner.shares == [share]
assert user1.shared_with_me == []
assert user2.shared_with_me == []
assert group2.shared_with_me == [share]
assert user2.all_shared_with_me == [share]
db.delete(share)
db.commit()
assert user1.shares == []
assert spawner.shares == []
assert user1.shared_with_me == []
assert user2.shared_with_me == []
assert group2.shared_with_me == []
assert user2.all_shared_with_me == []
def test_expiring_api_token(app, user):
db = app.db
token = orm.APIToken.new(expires_in=30, user=user)

View File

@@ -764,7 +764,7 @@ async def test_login_strip(app, form_user, auth_user, form_password):
(False, '', '', None),
# next_url is respected
(False, '/hub/admin', '/hub/admin', None),
(False, '/user/other', '/hub/user/other', None),
(False, '/user/other', '/user/other', None),
(False, '/absolute', '/absolute', None),
(False, '/has?query#andhash', '/has?query#andhash', None),
# :// in query string or fragment

View File

@@ -21,17 +21,10 @@ from .utils import add_user, api_request
def test_orm_roles(db):
"""Test orm roles setup"""
user_role = orm.Role.find(db, name='user')
user_role.users = []
token_role = orm.Role.find(db, name='token')
service_role = orm.Role.find(db, name='service')
if not user_role:
user_role = orm.Role(name='user', scopes=['self'])
db.add(user_role)
if not token_role:
token_role = orm.Role(name='token', scopes=['inherit'])
db.add(token_role)
if not service_role:
service_role = orm.Role(name='service', scopes=[])
db.add(service_role)
service_role = orm.Role(name="service")
db.add(service_role)
db.commit()
group_role = orm.Role(name='group', scopes=['read:users'])
@@ -376,9 +369,10 @@ async def test_creating_roles(app, role, role_def, response_type, response, capl
('default', 'user', 'error', ValueError),
],
)
async def test_delete_roles(db, role_type, rolename, response_type, response, caplog):
async def test_delete_roles(app, role_type, rolename, response_type, response, caplog):
"""Test raising errors and info when deleting roles"""
caplog.set_level(logging.INFO)
db = app.db
if response_type == 'info':
# add the role to db

View File

@@ -1,24 +1,24 @@
"""Test scopes for API handlers"""
import types
from operator import itemgetter
from unittest import mock
import pytest
from pytest import mark
from tornado import web
from tornado.httputil import HTTPServerRequest
from .. import orm, roles, scopes
from .._memoize import FrozenDict
from ..handlers import BaseHandler
from ..apihandlers import APIHandler
from ..scopes import (
Scope,
_check_scope_access,
_expand_self_scope,
_intersect_expanded_scopes,
_resolve_requested_scopes,
expand_scopes,
get_scopes_for,
has_scope,
identify_scopes,
needs_scope,
parse_scopes,
@@ -27,7 +27,8 @@ from .utils import add_user, api_request, auth_header
def get_handler_with_scopes(scopes):
handler = mock.Mock(spec=BaseHandler)
handler = mock.Mock(spec=APIHandler)
handler.has_scope = types.MethodType(APIHandler.has_scope, handler)
handler.parsed_scopes = parse_scopes(scopes)
return handler
@@ -56,47 +57,39 @@ def test_scope_precendence():
def test_scope_check_present():
handler = get_handler_with_scopes(['read:users'])
assert _check_scope_access(handler, 'read:users')
assert _check_scope_access(handler, 'read:users', user='maeby')
assert handler.has_scope('read:users')
assert handler.has_scope('read:users!user=maeby')
def test_scope_check_not_present():
handler = get_handler_with_scopes(['read:users!user=maeby'])
assert _check_scope_access(handler, 'read:users')
with pytest.raises(web.HTTPError):
_check_scope_access(handler, 'read:users', user='gob')
with pytest.raises(web.HTTPError):
_check_scope_access(handler, 'read:users', user='gob', server='server')
assert not handler.has_scope('read:users')
assert not handler.has_scope('read:users!user=gob')
assert not handler.has_scope('read:users!server=gob/server')
def test_scope_filters():
handler = get_handler_with_scopes(
['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
)
assert _check_scope_access(handler, 'read:users', group='bluth')
assert _check_scope_access(handler, 'read:users', user='maeby')
def test_scope_multiple_filters():
handler = get_handler_with_scopes(['read:users!user=george_michael'])
assert _check_scope_access(
handler, 'read:users', user='george_michael', group='bluths'
)
assert handler.has_scope('read:users!group=bluth')
assert handler.has_scope('read:users!user=maeby')
def test_scope_parse_server_name():
handler = get_handler_with_scopes(
['servers!server=maeby/server1', 'read:users!user=maeby']
)
assert _check_scope_access(handler, 'servers', user='maeby', server='server1')
assert handler.has_scope('servers!server=maeby/server1')
class MockAPIHandler:
def __init__(self):
self.expanded_scopes = {'users'}
self.parsed_scopes = {}
self.request = mock.Mock(spec=HTTPServerRequest)
self.request = mock.Mock(spec=APIHandler)
self.request.path = '/path'
self.db = None
def set_scopes(self, *scopes):
self.expanded_scopes = set(scopes)
@@ -295,7 +288,7 @@ async def test_exceeding_user_permissions(
api_token = user.new_api_token()
orm_api_token = orm.APIToken.find(app.db, token=api_token)
# store scopes user does not have
orm_api_token.scopes = orm_api_token.scopes + ['list:users', 'read:users']
orm_api_token.scopes = list(orm_api_token.scopes) + ['list:users', 'read:users']
headers = {'Authorization': 'token %s' % api_token}
r = await api_request(app, 'users', headers=headers)
assert r.status_code == 200
@@ -490,7 +483,7 @@ async def test_metascope_inherit_expansion(app, create_user_with_scopes):
assert user_scope_set == token_scope_set
# Check no roles means no permissions
token.scopes.clear()
token.scopes = []
app.db.commit()
token_scope_set = get_scopes_for(token)
assert isinstance(token_scope_set, frozenset)
@@ -556,7 +549,7 @@ async def test_server_state_access(
await api_request(
app, 'users', user.name, 'servers', server_name, method='post'
)
service = create_service_with_scopes("read:users:name!user=", *scopes)
service = create_service_with_scopes("read:users:name!user=bianca", *scopes)
api_token = service.new_api_token()
headers = {'Authorization': 'token %s' % api_token}
@@ -1309,3 +1302,64 @@ def test_resolve_requested_scopes(
)
assert allowed == expected_allowed
assert disallowed == expected_disallowed
@pytest.mark.parametrize(
"scope, have_scopes, ok",
[
# exact matches
("read:users", "read:users", True),
("read:users!user=USER", "read:users!user=USER", True),
("read:servers!server=USER/x", "read:servers!server=USER/x", True),
("read:groups!group=GROUP", "read:groups!group=GROUP", True),
# subscopes
("read:users:name", "read:users", True),
# subscopes with matching filter
("read:users:name!user=USER", "read:users!user=USER", True),
("read:users!user=USER", "read:users!group=GROUP", True),
("read:users!user=USER", "read:users", True),
("read:servers!server=USER/x", "read:servers", True),
("read:servers!server=USER/x", "servers!server=USER/x", True),
("read:servers!server=USER/x", "servers!user=USER", True),
("read:servers!server=USER/x", "servers!group=GROUP", True),
# shouldn't match
("read:users", "read:users!user=USER", False),
("read:users", "read:users!user=USER", False),
("read:users!user=USER", "read:users!user=other", False),
("read:users!user=USER", "read:users!group=other", False),
("read:servers!server=USER/x", "servers!server=other/x", False),
("read:servers!server=USER/x", "servers!user=other", False),
("read:servers!server=USER/x", "servers!group=other", False),
("servers!server=USER/x", "read:servers!server=USER/x", False),
],
)
def test_has_scope(app, user, group, scope, have_scopes, ok):
db = app.db
user.groups.append(group)
db.commit()
def _sub(scope):
return scope.replace("GROUP", group.name).replace("USER", user.name)
scope = _sub(scope)
have_scopes = [_sub(s) for s in have_scopes.split(",")]
parsed_scopes = parse_scopes(expand_scopes(have_scopes))
assert has_scope(scope, parsed_scopes, db=db) == ok
@pytest.mark.parametrize(
"scope, have_scopes, ok",
[
("read:users", "read:users", True),
("read:users", "read:users!user=x", True),
("read:users", "read:groups!user=x", False),
("read:users!user=x", "read:users!group=y", ValueError),
],
)
def test_has_scope_post_filter(scope, have_scopes, ok):
have_scopes = have_scopes.split(",")
if ok not in (True, False):
with pytest.raises(ok):
has_scope(scope, have_scopes, post_filter=True)
else:
assert has_scope(scope, have_scopes, post_filter=True) == ok

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ from .. import orm
from .. import spawner as spawnermod
from ..objects import Hub, Server
from ..scopes import access_scopes
from ..spawner import LocalProcessSpawner, Spawner
from ..spawner import SimpleLocalProcessSpawner, Spawner
from ..user import User
from ..utils import AnyTimeoutError, maybe_future, new_token, url_path_join
from .mocking import public_url
@@ -47,7 +47,7 @@ def setup():
def new_spawner(db, **kwargs):
user = kwargs.setdefault('user', User(db.query(orm.User).first(), {}))
user = kwargs.setdefault("user", User(db.query(orm.User).one(), {}))
kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep])
kwargs.setdefault('hub', Hub())
kwargs.setdefault('notebook_dir', os.getcwd())
@@ -57,7 +57,7 @@ def new_spawner(db, **kwargs):
kwargs.setdefault('term_timeout', 1)
kwargs.setdefault('kill_timeout', 1)
kwargs.setdefault('poll_interval', 1)
return user._new_spawner('', spawner_class=LocalProcessSpawner, **kwargs)
return user._new_spawner('', spawner_class=SimpleLocalProcessSpawner, **kwargs)
async def test_spawner(db, request):
@@ -381,7 +381,11 @@ async def test_spawner_bad_api_token(app):
(
["self", "read:groups!group=x", "users:activity"],
["admin:groups", "users:activity"],
["read:groups!group=x", "read:groups:name!group=x", "users:activity"],
[
"read:groups!group=x",
"read:groups:name!group=x",
"users:activity",
],
),
],
)

View File

@@ -10,6 +10,7 @@ packaging
pamela>=1.1.0; sys_platform != 'win32'
prometheus_client>=0.5.0
psutil>=5.6.5; sys_platform == 'win32'
pydantic>=2
python-dateutil
requests
SQLAlchemy>=1.4.1

View File

@@ -0,0 +1,51 @@
{% extends "page.html" %}
{% block login_widget %}
{% endblock %}
{% block main %}
<div class="container col-md-6 col-md-offset-3">
<h1 class="text-center">Accept sharing invitation</h1>
<h2>
You ({{ user.name }}) have been invited to access {{ owner.name }}'s server
{%- if spawner.name %} ({{ spawner.name }}){%- endif %} at <a href="{{ spawner_url | safe }}">{{ spawner_url }}</a>.
</h2>
{% if not spawner_ready %}
<p class="alert alert-danger">
The server at {{ spawner_url }} is not currently running.
After accepting permission, you may need to ask {{ owner.name }}
to start the server before you can access it.
</p>
{% endif %}
<p>
By accepting the invitation, you will be granted the following permissions,
restricted to this particular server:
</p>
<div>
<form method="POST" action="">
{# these are the 'real' inputs to the form -#}
<input type="hidden" name="_xsrf" value="{{ xsrf }}" />
<input type="hidden" name="code" value="{{ code }}" />
{% for scope_info in scope_descriptions -%}
<div class="checkbox input-group">
<label>
<span>
{{ scope_info['description'] }}
{% if scope_info['filter'] %}
Applies to {{ scope_info['filter'] }}. {% endif %}
</span>
</label>
</div>
{% endfor -%}
<p>
After accepting the invitation, you will be redirected to <a href="{{ next_url | safe }}">{{ next_url }}</a>.
</p>
<input type="submit" value="Accept invitation" class="form-control btn-jupyter" />
</form>
</div>
</div>
{% endblock %}