mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
BIN
docs/source/images/sharing-token.png
Normal file
BIN
docs/source/images/sharing-token.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
@@ -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
|
||||
|
@@ -21,6 +21,7 @@ services
|
||||
urls
|
||||
event-logging
|
||||
monitoring
|
||||
sharing
|
||||
gallery-jhub-deployments
|
||||
changelog
|
||||
api/index.md
|
||||
|
@@ -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`.
|
||||
|
398
docs/source/reference/sharing.md
Normal file
398
docs/source/reference/sharing.md
Normal 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.
|
@@ -51,5 +51,6 @@ Further tutorials of configuring JupyterHub for specific tasks
|
||||
```{toctree}
|
||||
:maxdepth: 1
|
||||
|
||||
sharing
|
||||
collaboration-users
|
||||
```
|
||||
|
287
docs/source/tutorial/sharing.md
Normal file
287
docs/source/tutorial/sharing.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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}/")
|
||||
```
|
77
examples/user-sharing/README.md
Normal file
77
examples/user-sharing/README.md
Normal 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.
|
37
examples/user-sharing/jupyterhub_config.py
Normal file
37
examples/user-sharing/jupyterhub_config.py
Normal 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
|
||||
]
|
685
examples/user-sharing/share-api.ipynb
Normal file
685
examples/user-sharing/share-api.ipynb
Normal 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
|
||||
}
|
403
examples/user-sharing/share-jupyterlab.ipynb
Normal file
403
examples/user-sharing/share-jupyterlab.ipynb
Normal 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
|
||||
}
|
@@ -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)
|
||||
|
@@ -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,
|
||||
|
590
jupyterhub/apihandlers/shares.py
Normal file
590
jupyterhub/apihandlers/shares.py
Normal 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),
|
||||
]
|
@@ -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)
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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),
|
||||
|
@@ -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
|
||||
# ------------------------------------
|
||||
|
@@ -47,6 +47,7 @@ def get_default_roles():
|
||||
'access:servers',
|
||||
'read:roles',
|
||||
'read:metrics',
|
||||
'shares',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@@ -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 proxy’s 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 = ""
|
||||
|
@@ -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")
|
||||
|
@@ -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):
|
||||
|
@@ -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)
|
||||
|
64
jupyterhub/tests/browser/test_share.py
Normal file
64
jupyterhub/tests/browser/test_share.py
Normal 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}/.*"))
|
@@ -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():
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
1487
jupyterhub/tests/test_shares.py
Normal file
1487
jupyterhub/tests/test_shares.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
@@ -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
|
||||
|
51
share/jupyterhub/templates/accept-share.html
Normal file
51
share/jupyterhub/templates/accept-share.html
Normal 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 %}
|
Reference in New Issue
Block a user