mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 03:23:04 +00:00
Merge branch 'jupyterhub:main' into group_property_feature
This commit is contained in:
@@ -6,7 +6,7 @@ info:
|
|||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
version: 2.4.0.dev
|
version: 3.0.0b1
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
@@ -139,6 +139,16 @@ paths:
|
|||||||
If unspecified, use api_page_default_limit.
|
If unspecified, use api_page_default_limit.
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
|
- name: include_stopped_servers
|
||||||
|
in: query
|
||||||
|
description: |
|
||||||
|
Include stopped servers in user model(s).
|
||||||
|
Added in JupyterHub 3.0.
|
||||||
|
Allows retrieval of information about stopped servers,
|
||||||
|
such as activity and state fields.
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
allowEmptyValue: true
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: The Hub's user list
|
description: The Hub's user list
|
||||||
@@ -564,7 +574,7 @@ paths:
|
|||||||
A list of role names from which to derive scopes.
|
A list of role names from which to derive scopes.
|
||||||
This is a shortcut for assigning collections of scopes;
|
This is a shortcut for assigning collections of scopes;
|
||||||
Tokens do not retain role assignment.
|
Tokens do not retain role assignment.
|
||||||
(Changed in 2.3: roles are immediately resolved to scopes
|
(Changed in 3.0: roles are immediately resolved to scopes
|
||||||
instead of stored on roles.)
|
instead of stored on roles.)
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
@@ -572,7 +582,7 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
description: |
|
description: |
|
||||||
A list of scopes that the token should have.
|
A list of scopes that the token should have.
|
||||||
(new in JupyterHub 2.3).
|
(new in JupyterHub 3.0).
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
@@ -1160,7 +1170,11 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
servers:
|
servers:
|
||||||
type: array
|
type: array
|
||||||
description: The active servers for this user.
|
description: |
|
||||||
|
The servers for this user.
|
||||||
|
By default: only includes _active_ servers.
|
||||||
|
Changed in 3.0: if `?include_stopped_servers` parameter is specified,
|
||||||
|
stopped servers will be included as well.
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Server"
|
$ref: "#/components/schemas/Server"
|
||||||
auth_state:
|
auth_state:
|
||||||
@@ -1182,6 +1196,15 @@ components:
|
|||||||
description: |
|
description: |
|
||||||
Whether the server is ready for traffic.
|
Whether the server is ready for traffic.
|
||||||
Will always be false when any transition is pending.
|
Will always be false when any transition is pending.
|
||||||
|
stopped:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Whether the server is stopped. Added in JupyterHub 3.0,
|
||||||
|
and only useful when using the `?include_stopped_servers`
|
||||||
|
request parameter.
|
||||||
|
Now that stopped servers may be included (since JupyterHub 3.0),
|
||||||
|
this is the simplest way to select stopped servers.
|
||||||
|
Always equivalent to `not (ready or pending)`.
|
||||||
pending:
|
pending:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
@@ -1327,15 +1350,15 @@ components:
|
|||||||
roles:
|
roles:
|
||||||
type: array
|
type: array
|
||||||
description:
|
description:
|
||||||
Deprecated in JupyterHub 2.3, always an empty list. Tokens
|
Deprecated in JupyterHub 3, always an empty list. Tokens have
|
||||||
have 'scopes' starting from JupyterHub 2.3.
|
'scopes' starting from JupyterHub 3.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
scopes:
|
scopes:
|
||||||
type: array
|
type: array
|
||||||
description:
|
description:
|
||||||
List of scopes this token has been assigned. New in JupyterHub
|
List of scopes this token has been assigned. New in JupyterHub
|
||||||
2.3. In JupyterHub 2.0-2.2, tokens were assigned 'roles' insead of scopes.
|
3. In JupyterHub 2.x, tokens were assigned 'roles' insead of scopes.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
note:
|
note:
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -48,7 +48,7 @@ version = '%i.%i' % jupyterhub.version_info[:2]
|
|||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = jupyterhub.__version__
|
release = jupyterhub.__version__
|
||||||
|
|
||||||
language = None
|
language = "en"
|
||||||
exclude_patterns = []
|
exclude_patterns = []
|
||||||
pygments_style = 'sphinx'
|
pygments_style = 'sphinx'
|
||||||
todo_include_todos = False
|
todo_include_todos = False
|
||||||
|
BIN
docs/source/images/dropdown-details-3.0.png
Normal file
BIN
docs/source/images/dropdown-details-3.0.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
@@ -1,3 +1,5 @@
|
|||||||
|
(RBAC)=
|
||||||
|
|
||||||
# JupyterHub RBAC
|
# JupyterHub RBAC
|
||||||
|
|
||||||
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
|
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
|
||||||
|
@@ -27,7 +27,6 @@ Roles can be assigned to the following entities:
|
|||||||
- Users
|
- Users
|
||||||
- Services
|
- Services
|
||||||
- Groups
|
- Groups
|
||||||
- Tokens
|
|
||||||
|
|
||||||
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
|
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ Services do not have a default role. Services without roles have no access to th
|
|||||||
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
|
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
|
||||||
|
|
||||||
**Tokens** \
|
**Tokens** \
|
||||||
A token’s permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific scopes are requested for a new token, the token is assigned the `token` role.
|
A token’s permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific scopes are requested for a new token, the token is assigned the scopes of the `token` role.
|
||||||
|
|
||||||
(define-role-target)=
|
(define-role-target)=
|
||||||
|
|
||||||
|
@@ -72,6 +72,8 @@ Requested resources are filtered based on the filter of the corresponding scope.
|
|||||||
|
|
||||||
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
||||||
|
|
||||||
|
(self-referencing-filters)=
|
||||||
|
|
||||||
### Self-referencing filters
|
### Self-referencing filters
|
||||||
|
|
||||||
There are some 'shortcut' filters,
|
There are some 'shortcut' filters,
|
||||||
@@ -82,7 +84,7 @@ The `!user` filter is a special horizontal filter that strictly refers to the **
|
|||||||
|
|
||||||
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
||||||
|
|
||||||
:::{versionadded} 2.3
|
:::{versionadded} 3.0
|
||||||
`!service` and `!server` filters.
|
`!service` and `!server` filters.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@@ -130,17 +132,58 @@ There are four exceptions to the general {ref}`scope conventions <scope-conventi
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::{versionadded} 3.0
|
||||||
|
The `admin-ui` scope is added to explicitly grant access to the admin page,
|
||||||
|
rather than combining `admin:users` and `admin:servers` permissions.
|
||||||
|
This means a deployment can enable the admin page with only a subset of functionality enabled.
|
||||||
|
|
||||||
|
Note that this means actions to take _via_ the admin UI
|
||||||
|
and access _to_ the admin UI are separated.
|
||||||
|
For example, it generally doesn't make sense to grant
|
||||||
|
`admin-ui` without at least `list:users` for at least some subset of users.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "instructor-data8",
|
||||||
|
"scopes": [
|
||||||
|
# access to the admin page
|
||||||
|
"admin-ui",
|
||||||
|
# list users in the class group
|
||||||
|
"list:users!group=students-data8",
|
||||||
|
# start/stop servers for users in the class
|
||||||
|
"admin:servers!group=students-data8",
|
||||||
|
# access servers for users in the class
|
||||||
|
"access:servers!group=students-data8",
|
||||||
|
],
|
||||||
|
"group": ["instructors-data8"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
will grant instructors in the data8 course permission to:
|
||||||
|
|
||||||
|
1. view the admin UI
|
||||||
|
2. see students in the class (but not all users)
|
||||||
|
3. start/stop/access servers for users in the class
|
||||||
|
4. but _not_ permission to administer the users themselves (e.g. change their permissions, etc.)
|
||||||
|
:::
|
||||||
|
|
||||||
```{Caution}
|
```{Caution}
|
||||||
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
|
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
|
||||||
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(custom-scopes)=
|
||||||
|
|
||||||
### Custom scopes
|
### Custom scopes
|
||||||
|
|
||||||
:::{versionadded} 2.3
|
:::{versionadded} 3.0
|
||||||
:::
|
:::
|
||||||
|
|
||||||
JupyterHub 2.3 introduces support for custom scopes.
|
JupyterHub 3.0 introduces support for custom scopes.
|
||||||
Services that use JupyterHub for authentication and want to implement their own granular access may define additional _custom_ scopes and assign them to users with JupyterHub roles.
|
Services that use JupyterHub for authentication and want to implement their own granular access may define additional _custom_ scopes and assign them to users with JupyterHub roles.
|
||||||
|
|
||||||
% Note: keep in sync with pattern/description in jupyterhub/scopes.py
|
% Note: keep in sync with pattern/description in jupyterhub/scopes.py
|
||||||
@@ -231,7 +274,12 @@ async def write_something(request):
|
|||||||
|
|
||||||
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
|
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
|
||||||
against the `.hub_scopes` attribute of each Handler
|
against the `.hub_scopes` attribute of each Handler
|
||||||
(the default is populated from `$JUPYTERHUB_OAUTH_SCOPES` and usually `access:services!service=myservice`).
|
(the default is populated from `$JUPYTERHUB_OAUTH_ACCESS_SCOPES` and usually `access:services!service=myservice`).
|
||||||
|
|
||||||
|
:::{versionchanged} 3.0
|
||||||
|
The JUPYTERHUB_OAUTH_SCOPES environment variable is deprecated and renamed to JUPYTERHUB_OAUTH_ACCESS_SCOPES,
|
||||||
|
to avoid ambiguity with JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES
|
||||||
|
:::
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
@@ -22,39 +22,47 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
|
|||||||
|
|
||||||
## Resolving roles and scopes
|
## Resolving roles and scopes
|
||||||
|
|
||||||
**Resolving roles** refers to determining which roles a user, service, token, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
|
**Resolving roles** refers to determining which roles a user, service, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
|
||||||
|
|
||||||
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
|
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
|
||||||
|
|
||||||
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific roles or making an API request. The following sections provide more details.
|
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific scopes or making an API request. The following sections provide more details.
|
||||||
|
|
||||||
(requesting-api-token-target)=
|
(requesting-api-token-target)=
|
||||||
|
|
||||||
### Requesting API token with specific roles
|
### Requesting API token with specific scopes
|
||||||
|
|
||||||
:::{versionchanged} 2.3
|
:::{versionchanged} 3.0
|
||||||
API tokens have _scopes_ instead of roles,
|
API tokens have _scopes_ instead of roles,
|
||||||
so that their permissions cannot be updated.
|
so that their permissions cannot be updated.
|
||||||
|
|
||||||
You may still request roles for a token,
|
You may still request roles for a token,
|
||||||
but those roles will be evaluated to the corresponding _scopes_ immediately.
|
but those roles will be evaluated to the corresponding _scopes_ immediately.
|
||||||
|
|
||||||
|
Prior to 3.0, tokens stored _roles_,
|
||||||
|
which meant their scopes were resolved on each request.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific permissions.
|
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific permissions.
|
||||||
As of JupyterHub 2.3, it is only possible to specify scopes for a token through the _POST /users/:name/tokens_ API where the scopes can be specified in the token parameters body (see [](../reference/rest-api.rst)).
|
|
||||||
|
|
||||||
RBAC adds several steps into the token issue flow.
|
RBAC is involved in several stages of the OAuth token flow.
|
||||||
|
|
||||||
If no scopes are requested, the token is issued with the permissions stored on the default `token` role
|
When requesting a token via the tokens API (`/users/:name/tokens`), or the token page (`/hub/token`),
|
||||||
|
if no scopes are requested, the token is issued with the permissions stored on the default `token` role
|
||||||
(providing the requester is allowed to create the token).
|
(providing the requester is allowed to create the token).
|
||||||
|
|
||||||
|
OAuth tokens are also requested via OAuth flow
|
||||||
|
|
||||||
If the token is requested with any scopes, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
|
If the token is requested with any scopes, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
|
||||||
|
|
||||||
If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed.
|
If, due to modifications of permissions of the token or token owner,
|
||||||
The API request is resolved without additional errors using the scope _intersection_,
|
at API request time a token has any scopes that its owner does not,
|
||||||
but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
|
those scopes are removed.
|
||||||
|
The API request is resolved without additional errors using the scope _intersection_;
|
||||||
|
the Hub logs a warning in this case (see {ref}`Figure 2 <api-request-chart>`).
|
||||||
|
|
||||||
Resolving a token's scope (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error.
|
Resolving a token's scope (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's own scopes into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy.
|
||||||
|
If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested scopes; if not, JupyterHub will raise an error.
|
||||||
|
|
||||||
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
|
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
|
||||||
|
|
||||||
@@ -67,9 +75,9 @@ Figure 1. Resolving roles and scopes during API token request
|
|||||||
|
|
||||||
### Making an API request
|
### Making an API request
|
||||||
|
|
||||||
With the RBAC framework each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
|
With the RBAC framework, each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
|
||||||
|
|
||||||
When an API request is performed, the requesting API token's roles are again resolved (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
|
When an API request is performed, the requesting API token's scopes are again intersected with its owner's (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
|
||||||
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
|
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
|
||||||
|
|
||||||
The passed scopes are compared to the scopes required to access the API as follows:
|
The passed scopes are compared to the scopes required to access the API as follows:
|
||||||
|
@@ -116,7 +116,10 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
|||||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||||
Only for proxied web services.
|
Only for proxied web services.
|
||||||
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
|
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service
|
||||||
|
(deprecated in 3.0, use JUPYTERHUB_OAUTH_ACCESS_SCOPES).
|
||||||
|
JUPYTERHUB_OAUTH_ACCESS_SCOPES: JSON-serialized list of scopes to use for allowing access to the service (new in 3.0).
|
||||||
|
JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES: JSON-serialized list of scopes that can be requested by the oauth client on behalf of users (new in 3.0).
|
||||||
```
|
```
|
||||||
|
|
||||||
For the previous 'cull idle' Service example, these environment variables
|
For the previous 'cull idle' Service example, these environment variables
|
||||||
@@ -376,7 +379,7 @@ The `scopes` field can be used to manage access.
|
|||||||
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
||||||
Individual permissions may be revoked at any later point without revoking the token,
|
Individual permissions may be revoked at any later point without revoking the token,
|
||||||
in which case the `scopes` field in this model should be checked on each access.
|
in which case the `scopes` field in this model should be checked on each access.
|
||||||
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
|
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_ACCESS_SCOPES`.
|
||||||
|
|
||||||
An example of using an Externally-Managed Service and authentication is
|
An example of using an Externally-Managed Service and authentication is
|
||||||
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||||
|
@@ -308,6 +308,9 @@ The process environment is returned by `Spawner.get_env`, which specifies the fo
|
|||||||
This is also the OAuth client secret.
|
This is also the OAuth client secret.
|
||||||
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
|
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
|
||||||
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
|
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
|
||||||
|
- JUPYTERHUB_OAUTH_ACCESS_SCOPES - the scopes required to access the server (called JUPYTERHUB_OAUTH_SCOPES prior to 3.0)
|
||||||
|
- JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES - the scopes the service is allowed to request.
|
||||||
|
If no scopes are requested explicitly, these scopes will be requested.
|
||||||
|
|
||||||
Optional environment variables, depending on configuration:
|
Optional environment variables, depending on configuration:
|
||||||
|
|
||||||
|
@@ -7,7 +7,10 @@ c.JupyterHub.services = [
|
|||||||
'name': 'grades',
|
'name': 'grades',
|
||||||
'url': 'http://127.0.0.1:10101',
|
'url': 'http://127.0.0.1:10101',
|
||||||
'command': [sys.executable, './grades.py'],
|
'command': [sys.executable, './grades.py'],
|
||||||
'oauth_roles': ['grader'],
|
'oauth_client_allowed_scopes': [
|
||||||
|
'custom:grades:write',
|
||||||
|
'custom:grades:read',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@@ -26,7 +26,7 @@ After logging in with any username and password, you should see a JSON dump of y
|
|||||||
```
|
```
|
||||||
|
|
||||||
What is contained in the model will depend on the permissions
|
What is contained in the model will depend on the permissions
|
||||||
requested in the `oauth_roles` configuration of the service `whoami-oauth` service.
|
requested in the `oauth_client_allowed_scopes` configuration of the service `whoami-oauth` service.
|
||||||
The default is the minimum required for identification and access to the service,
|
The default is the minimum required for identification and access to the service,
|
||||||
which will provide the username and current scopes.
|
which will provide the username and current scopes.
|
||||||
|
|
||||||
|
@@ -14,11 +14,11 @@ c.JupyterHub.services = [
|
|||||||
# only requesting access to the service,
|
# only requesting access to the service,
|
||||||
# and identification by name,
|
# and identification by name,
|
||||||
# nothing more.
|
# nothing more.
|
||||||
# Specifying 'oauth_roles' as a list of role names
|
# Specifying 'oauth_client_allowed_scopes' as a list of scopes
|
||||||
# allows requesting more information about users,
|
# allows requesting more information about users,
|
||||||
# or the ability to take actions on users' behalf, as required.
|
# or the ability to take actions on users' behalf, as required.
|
||||||
# The default 'token' role has the full permissions of its owner:
|
# the 'inherit' scope means the full permissions of the owner
|
||||||
# 'oauth_roles': ['token'],
|
# 'oauth_client_allowed_scopes': ['inherit'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@@ -28,17 +28,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.12.3",
|
|
||||||
"@babel/preset-env": "^7.12.11",
|
|
||||||
"@babel/preset-react": "^7.12.10",
|
|
||||||
"@testing-library/jest-dom": "^5.15.1",
|
|
||||||
"@testing-library/react": "^12.1.2",
|
|
||||||
"@testing-library/user-event": "^13.5.0",
|
|
||||||
"babel-loader": "^8.2.1",
|
|
||||||
"bootstrap": "^4.5.3",
|
"bootstrap": "^4.5.3",
|
||||||
"css-loader": "^5.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "^1.1.1",
|
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"history": "^5.0.0",
|
"history": "^5.0.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
@@ -51,24 +41,35 @@
|
|||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.2.2",
|
||||||
"react-router": "^5.2.0",
|
"react-router": "^5.2.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"recompose": "^0.30.0",
|
"recompose": "npm:react-recompose@^0.31.2",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"regenerator-runtime": "^0.13.9",
|
"regenerator-runtime": "^0.13.9"
|
||||||
"style-loader": "^2.0.0",
|
|
||||||
"webpack": "^5.6.0",
|
|
||||||
"webpack-cli": "^3.3.4",
|
|
||||||
"webpack-dev-server": "^3.11.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.12.3",
|
||||||
|
"@babel/preset-env": "^7.12.11",
|
||||||
|
"@babel/preset-react": "^7.12.10",
|
||||||
|
"@testing-library/jest-dom": "^5.15.1",
|
||||||
|
"@testing-library/react": "^12.1.2",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@webpack-cli/serve": "^1.7.0",
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
|
"babel-loader": "^8.2.1",
|
||||||
|
"css-loader": "^5.0.1",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"eslint": "^7.18.0",
|
"eslint": "^7.18.0",
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
|
"eslint-plugin-unused-imports": "^1.1.1",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"sinon": "^13.0.1"
|
"sinon": "^13.0.1",
|
||||||
|
"style-loader": "^2.0.0",
|
||||||
|
"webpack": "^5.6.0",
|
||||||
|
"webpack-cli": "^4.10.0",
|
||||||
|
"webpack-dev-server": "^4.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -22,15 +22,16 @@ const store = createStore(reducers, initialState);
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let { limit, user_page, groups_page } = initialState;
|
let { limit, user_page, groups_page } = initialState;
|
||||||
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
|
let api = withAPI()().props;
|
||||||
.then((data) => data.json())
|
api
|
||||||
|
.updateUsers(user_page * limit, limit)
|
||||||
.then((data) =>
|
.then((data) =>
|
||||||
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
|
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
|
||||||
)
|
)
|
||||||
.catch((err) => console.log(err));
|
.catch((err) => console.log(err));
|
||||||
|
|
||||||
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
|
api
|
||||||
.then((data) => data.json())
|
.updateGroups(groups_page * limit, limit)
|
||||||
.then((data) =>
|
.then((data) =>
|
||||||
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
||||||
)
|
)
|
||||||
|
@@ -201,6 +201,25 @@ const ServerDashboard = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ServerRowTable = ({ data }) => {
|
const ServerRowTable = ({ data }) => {
|
||||||
|
const sortedData = Object.keys(data)
|
||||||
|
.sort()
|
||||||
|
.reduce(function (result, key) {
|
||||||
|
let value = data[key];
|
||||||
|
switch (key) {
|
||||||
|
case "last_activity":
|
||||||
|
case "created":
|
||||||
|
case "started":
|
||||||
|
// format timestamps
|
||||||
|
value = value ? timeSince(value) : value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// cast arrays (e.g. roles, groups) to string
|
||||||
|
value = value.sort().join(", ");
|
||||||
|
}
|
||||||
|
result[key] = value;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
return (
|
return (
|
||||||
<ReactObjectTableViewer
|
<ReactObjectTableViewer
|
||||||
className="table-striped table-bordered"
|
className="table-striped table-bordered"
|
||||||
@@ -214,7 +233,7 @@ const ServerDashboard = (props) => {
|
|||||||
valueStyle={{
|
valueStyle={{
|
||||||
padding: "4px",
|
padding: "4px",
|
||||||
}}
|
}}
|
||||||
data={data}
|
data={sortedData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -251,11 +270,7 @@ const ServerDashboard = (props) => {
|
|||||||
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
||||||
|
|
||||||
<td data-testid="user-row-server">
|
<td data-testid="user-row-server">
|
||||||
{server.name ? (
|
<p className="text-secondary">{server.name}</p>
|
||||||
<p className="text-secondary">{server.name}</p>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td data-testid="user-row-last-activity">
|
<td data-testid="user-row-last-activity">
|
||||||
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||||
|
@@ -4,7 +4,9 @@ import { jhapiRequest } from "./jhapiUtil";
|
|||||||
const withAPI = withProps(() => ({
|
const withAPI = withProps(() => ({
|
||||||
updateUsers: (offset, limit, name_filter) =>
|
updateUsers: (offset, limit, name_filter) =>
|
||||||
jhapiRequest(
|
jhapiRequest(
|
||||||
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`,
|
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
|
||||||
|
name_filter || ""
|
||||||
|
}`,
|
||||||
"GET"
|
"GET"
|
||||||
).then((data) => data.json()),
|
).then((data) => data.json()),
|
||||||
updateGroups: (offset, limit) =>
|
updateGroups: (offset, limit) =>
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const express = require("express");
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: path.resolve(__dirname, "src", "App.jsx"),
|
entry: path.resolve(__dirname, "src", "App.jsx"),
|
||||||
@@ -34,16 +33,19 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: [new webpack.HotModuleReplacementPlugin()],
|
plugins: [new webpack.HotModuleReplacementPlugin()],
|
||||||
devServer: {
|
devServer: {
|
||||||
contentBase: path.resolve(__dirname, "build"),
|
static: {
|
||||||
|
directory: path.resolve(__dirname, "build"),
|
||||||
|
},
|
||||||
port: 9000,
|
port: 9000,
|
||||||
before: (app, server) => {
|
onBeforeSetupMiddleware: (devServer) => {
|
||||||
|
const app = devServer.app;
|
||||||
|
|
||||||
var user_data = JSON.parse(
|
var user_data = JSON.parse(
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||||
);
|
);
|
||||||
var group_data = JSON.parse(
|
var group_data = JSON.parse(
|
||||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
||||||
);
|
);
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// get user_data
|
// get user_data
|
||||||
app.get("/hub/api/users", (req, res) => {
|
app.get("/hub/api/users", (req, res) => {
|
||||||
|
4240
jsx/yarn.lock
4240
jsx/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
# version_info updated by running `tbump`
|
# version_info updated by running `tbump`
|
||||||
version_info = (2, 4, 0, "", "dev")
|
version_info = (3, 0, 0, "b1", "")
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
|
@@ -13,12 +13,11 @@ depends_on = None
|
|||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy import Column, ForeignKey, Integer, Table, Unicode
|
from sqlalchemy import Column, ForeignKey, Table
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from jupyterhub import orm, roles
|
from jupyterhub import orm, roles, scopes
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
@@ -33,45 +32,84 @@ def upgrade():
|
|||||||
if 'oauth_codes' in tables:
|
if 'oauth_codes' in tables:
|
||||||
op.add_column('oauth_codes', sa.Column('scopes', orm.JSONList(), nullable=True))
|
op.add_column('oauth_codes', sa.Column('scopes', orm.JSONList(), nullable=True))
|
||||||
|
|
||||||
if 'api_tokens' not in tables:
|
if 'api_tokens' in tables:
|
||||||
|
# may not be present,
|
||||||
# e.g. upgrade from 1.x, token table dropped
|
# e.g. upgrade from 1.x, token table dropped
|
||||||
# no migration to do
|
# in which case no migration to do
|
||||||
return
|
|
||||||
|
|
||||||
# define new scopes column on API tokens
|
# define new scopes column on API tokens
|
||||||
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
|
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
|
||||||
|
|
||||||
if 'api_token_role_map' in tables:
|
if 'api_token_role_map' in tables:
|
||||||
# redefine the to-be-removed api_token->role relationship
|
# redefine the to-be-removed api_token->role relationship
|
||||||
# so we can run a query on it for the migration
|
# so we can run a query on it for the migration
|
||||||
token_role_map = Table(
|
token_role_map = Table(
|
||||||
"api_token_role_map",
|
"api_token_role_map",
|
||||||
orm.Base.metadata,
|
orm.Base.metadata,
|
||||||
Column(
|
Column(
|
||||||
'api_token_id',
|
'api_token_id',
|
||||||
ForeignKey('api_tokens.id', ondelete='CASCADE'),
|
ForeignKey('api_tokens.id', ondelete='CASCADE'),
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
'role_id',
|
'role_id',
|
||||||
ForeignKey('roles.id', ondelete='CASCADE'),
|
ForeignKey('roles.id', ondelete='CASCADE'),
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
),
|
),
|
||||||
extend_existing=True,
|
extend_existing=True,
|
||||||
|
)
|
||||||
|
orm.APIToken.roles = relationship('Role', secondary='api_token_role_map')
|
||||||
|
|
||||||
|
# tokens have roles, evaluate to scopes
|
||||||
|
db = Session(bind=c)
|
||||||
|
for token in db.query(orm.APIToken):
|
||||||
|
token.scopes = list(roles.roles_to_scopes(token.roles))
|
||||||
|
db.commit()
|
||||||
|
# drop token-role relationship
|
||||||
|
op.drop_table('api_token_role_map')
|
||||||
|
|
||||||
|
if 'oauth_clients' in tables:
|
||||||
|
# define new scopes column on API tokens
|
||||||
|
op.add_column(
|
||||||
|
'oauth_clients', sa.Column('allowed_scopes', orm.JSONList(), nullable=True)
|
||||||
)
|
)
|
||||||
orm.APIToken.roles = relationship('Role', secondary='api_token_role_map')
|
|
||||||
|
|
||||||
# tokens have roles, evaluate to scopes
|
if 'oauth_client_role_map' in tables:
|
||||||
db = Session(bind=c)
|
# redefine the to-be-removed api_token->role relationship
|
||||||
for token in db.query(orm.APIToken):
|
# so we can run a query on it for the migration
|
||||||
token.scopes = list(roles.roles_to_scopes(token.roles))
|
client_role_map = Table(
|
||||||
db.commit()
|
"oauth_client_role_map",
|
||||||
# drop token-role relationship
|
orm.Base.metadata,
|
||||||
op.drop_table('api_token_role_map')
|
Column(
|
||||||
|
'oauth_client_id',
|
||||||
|
ForeignKey('oauth_clients.id', ondelete='CASCADE'),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
'role_id',
|
||||||
|
ForeignKey('roles.id', ondelete='CASCADE'),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
extend_existing=True,
|
||||||
|
)
|
||||||
|
orm.OAuthClient.allowed_roles = relationship(
|
||||||
|
'Role', secondary='oauth_client_role_map'
|
||||||
|
)
|
||||||
|
|
||||||
|
# oauth clients have allowed_roles, evaluate to allowed_scopes
|
||||||
|
db = Session(bind=c)
|
||||||
|
for oauth_client in db.query(orm.OAuthClient):
|
||||||
|
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
|
||||||
|
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||||
|
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||||
|
db.commit()
|
||||||
|
# drop token-role relationship
|
||||||
|
op.drop_table('oauth_client_role_map')
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# cannot map permissions from scopes back to roles
|
# cannot map permissions from scopes back to roles
|
||||||
# drop whole api token table (revokes all tokens), which will be recreated on hub start
|
# drop whole api token table (revokes all tokens), which will be recreated on hub start
|
||||||
op.drop_table('api_tokens')
|
op.drop_table('api_tokens')
|
||||||
|
op.drop_table('oauth_clients')
|
||||||
op.drop_table('oauth_codes')
|
op.drop_table('oauth_codes')
|
||||||
|
@@ -187,22 +187,44 @@ class APIHandler(BaseHandler):
|
|||||||
json.dumps({'status': status_code, 'message': message or status_message})
|
json.dumps({'status': status_code, 'message': message or status_message})
|
||||||
)
|
)
|
||||||
|
|
||||||
def server_model(self, spawner):
|
def server_model(self, spawner, *, user=None):
|
||||||
"""Get the JSON model for a Spawner
|
"""Get the JSON model for a Spawner
|
||||||
Assume server permission already granted"""
|
Assume server permission already granted
|
||||||
|
"""
|
||||||
|
if isinstance(spawner, orm.Spawner):
|
||||||
|
# if an orm.Spawner is passed,
|
||||||
|
# create a model for a stopped Spawner
|
||||||
|
# not all info is available without the higher-level Spawner wrapper
|
||||||
|
orm_spawner = spawner
|
||||||
|
pending = None
|
||||||
|
ready = False
|
||||||
|
stopped = True
|
||||||
|
user = user
|
||||||
|
if user is None:
|
||||||
|
raise RuntimeError("Must specify User with orm.Spawner")
|
||||||
|
state = orm_spawner.state
|
||||||
|
else:
|
||||||
|
orm_spawner = spawner.orm_spawner
|
||||||
|
pending = spawner.pending
|
||||||
|
ready = spawner.ready
|
||||||
|
user = spawner.user
|
||||||
|
stopped = not spawner.active
|
||||||
|
state = spawner.get_state()
|
||||||
|
|
||||||
model = {
|
model = {
|
||||||
'name': spawner.name,
|
'name': orm_spawner.name,
|
||||||
'last_activity': isoformat(spawner.orm_spawner.last_activity),
|
'last_activity': isoformat(orm_spawner.last_activity),
|
||||||
'started': isoformat(spawner.orm_spawner.started),
|
'started': isoformat(orm_spawner.started),
|
||||||
'pending': spawner.pending,
|
'pending': pending,
|
||||||
'ready': spawner.ready,
|
'ready': ready,
|
||||||
'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'),
|
'stopped': stopped,
|
||||||
|
'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
|
||||||
'user_options': spawner.user_options,
|
'user_options': spawner.user_options,
|
||||||
'progress_url': spawner._progress_url,
|
'progress_url': user.progress_url(spawner.name),
|
||||||
}
|
}
|
||||||
scope_filter = self.get_scope_filter('admin:server_state')
|
scope_filter = self.get_scope_filter('admin:server_state')
|
||||||
if scope_filter(spawner, kind='server'):
|
if scope_filter(spawner, kind='server'):
|
||||||
model['state'] = spawner.get_state()
|
model['state'] = state
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def token_model(self, token):
|
def token_model(self, token):
|
||||||
@@ -248,10 +270,22 @@ class APIHandler(BaseHandler):
|
|||||||
keys.update(allowed_keys)
|
keys.update(allowed_keys)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
_include_stopped_servers = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def include_stopped_servers(self):
|
||||||
|
"""Whether stopped servers should be included in user models"""
|
||||||
|
if self._include_stopped_servers is None:
|
||||||
|
self._include_stopped_servers = self.get_argument(
|
||||||
|
"include_stopped_servers", "0"
|
||||||
|
).lower() not in {"0", "false"}
|
||||||
|
return self._include_stopped_servers
|
||||||
|
|
||||||
def user_model(self, user):
|
def user_model(self, user):
|
||||||
"""Get the JSON model for a User object"""
|
"""Get the JSON model for a User object"""
|
||||||
if isinstance(user, orm.User):
|
if isinstance(user, orm.User):
|
||||||
user = self.users[user.id]
|
user = self.users[user.id]
|
||||||
|
include_stopped_servers = self.include_stopped_servers
|
||||||
model = {
|
model = {
|
||||||
'kind': 'user',
|
'kind': 'user',
|
||||||
'name': user.name,
|
'name': user.name,
|
||||||
@@ -291,18 +325,29 @@ class APIHandler(BaseHandler):
|
|||||||
if '' in user.spawners and 'pending' in allowed_keys:
|
if '' in user.spawners and 'pending' in allowed_keys:
|
||||||
model['pending'] = user.spawners[''].pending
|
model['pending'] = user.spawners[''].pending
|
||||||
|
|
||||||
servers = model['servers'] = {}
|
servers = {}
|
||||||
scope_filter = self.get_scope_filter('read:servers')
|
scope_filter = self.get_scope_filter('read:servers')
|
||||||
for name, spawner in user.spawners.items():
|
for name, spawner in user.spawners.items():
|
||||||
# include 'active' servers, not just ready
|
# include 'active' servers, not just ready
|
||||||
# (this includes pending events)
|
# (this includes pending events)
|
||||||
if spawner.active and scope_filter(spawner, kind='server'):
|
if (spawner.active or include_stopped_servers) and scope_filter(
|
||||||
|
spawner, kind='server'
|
||||||
|
):
|
||||||
servers[name] = self.server_model(spawner)
|
servers[name] = self.server_model(spawner)
|
||||||
if not servers and 'servers' not in allowed_keys:
|
|
||||||
|
if include_stopped_servers:
|
||||||
|
# add any stopped servers in the db
|
||||||
|
seen = set(servers.keys())
|
||||||
|
for name, orm_spawner in user.orm_spawners.items():
|
||||||
|
if name not in seen and scope_filter(orm_spawner, kind='server'):
|
||||||
|
servers[name] = self.server_model(orm_spawner, user=user)
|
||||||
|
|
||||||
|
if "servers" in allowed_keys or servers:
|
||||||
# omit servers if no access
|
# omit servers if no access
|
||||||
# leave present and empty
|
# leave present and empty
|
||||||
# if request has access to read servers in general
|
# if request has access to read servers in general
|
||||||
model.pop('servers')
|
model["servers"] = servers
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def group_model(self, group):
|
def group_model(self, group):
|
||||||
|
@@ -2378,21 +2378,34 @@ class JupyterHub(Application):
|
|||||||
service.orm.server = None
|
service.orm.server = None
|
||||||
|
|
||||||
if service.oauth_available:
|
if service.oauth_available:
|
||||||
allowed_roles = []
|
allowed_scopes = set()
|
||||||
|
if service.oauth_client_allowed_scopes:
|
||||||
|
allowed_scopes.update(service.oauth_client_allowed_scopes)
|
||||||
if service.oauth_roles:
|
if service.oauth_roles:
|
||||||
allowed_roles = list(
|
if not allowed_scopes:
|
||||||
self.db.query(orm.Role).filter(
|
# DEPRECATED? It's still convenient and valid,
|
||||||
orm.Role.name.in_(service.oauth_roles)
|
# e.g. 'admin'
|
||||||
|
allowed_roles = list(
|
||||||
|
self.db.query(orm.Role).filter(
|
||||||
|
orm.Role.name.in_(service.oauth_roles)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
|
||||||
|
else:
|
||||||
|
self.log.warning(
|
||||||
|
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
|
||||||
|
f" using oauth_client_allowed_scopes={allowed_scopes}."
|
||||||
)
|
)
|
||||||
)
|
|
||||||
oauth_client = self.oauth_provider.add_client(
|
oauth_client = self.oauth_provider.add_client(
|
||||||
client_id=service.oauth_client_id,
|
client_id=service.oauth_client_id,
|
||||||
client_secret=service.api_token,
|
client_secret=service.api_token,
|
||||||
redirect_uri=service.oauth_redirect_uri,
|
redirect_uri=service.oauth_redirect_uri,
|
||||||
allowed_roles=allowed_roles,
|
|
||||||
description="JupyterHub service %s" % service.name,
|
description="JupyterHub service %s" % service.name,
|
||||||
)
|
)
|
||||||
service.orm.oauth_client = oauth_client
|
service.orm.oauth_client = oauth_client
|
||||||
|
# add access-scopes, derived from OAuthClient itself
|
||||||
|
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||||
|
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||||
else:
|
else:
|
||||||
if service.oauth_client:
|
if service.oauth_client:
|
||||||
self.db.delete(service.oauth_client)
|
self.db.delete(service.oauth_client)
|
||||||
|
@@ -151,7 +151,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
)
|
)
|
||||||
if orm_client is None:
|
if orm_client is None:
|
||||||
raise ValueError("No such client: %s" % client_id)
|
raise ValueError("No such client: %s" % client_id)
|
||||||
scopes = roles_to_scopes(orm_client.allowed_roles)
|
scopes = set(orm_client.allowed_scopes)
|
||||||
if 'inherit' not in scopes:
|
if 'inherit' not in scopes:
|
||||||
# add identify-user scope
|
# add identify-user scope
|
||||||
# and access-service scope
|
# and access-service scope
|
||||||
@@ -569,8 +569,8 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
requested_scopes.discard("identify")
|
requested_scopes.discard("identify")
|
||||||
|
|
||||||
# TODO: handle roles->scopes transition
|
# TODO: handle roles->scopes transition
|
||||||
# In 2.0-2.2, `?scopes=` only accepted _role_ names,
|
# In 2.x, `?scopes=` only accepted _role_ names,
|
||||||
# but in 2.3 we accept and prefer scopes.
|
# but in 3.0 we accept and prefer scopes.
|
||||||
# For backward-compatibility, we still accept both.
|
# For backward-compatibility, we still accept both.
|
||||||
# Should roles be deprecated here, or kept as a convenience?
|
# Should roles be deprecated here, or kept as a convenience?
|
||||||
try:
|
try:
|
||||||
@@ -589,7 +589,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
|||||||
)
|
)
|
||||||
requested_scopes = roles_to_scopes(requested_roles)
|
requested_scopes = roles_to_scopes(requested_roles)
|
||||||
|
|
||||||
client_allowed_scopes = roles_to_scopes(orm_client.allowed_roles)
|
client_allowed_scopes = set(orm_client.allowed_scopes)
|
||||||
|
|
||||||
# always grant reading the token-owner's name
|
# always grant reading the token-owner's name
|
||||||
# and accessing the service itself
|
# and accessing the service itself
|
||||||
@@ -624,7 +624,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
|||||||
super().__init__(validator, *args, **kwargs)
|
super().__init__(validator, *args, **kwargs)
|
||||||
|
|
||||||
def add_client(
|
def add_client(
|
||||||
self, client_id, client_secret, redirect_uri, allowed_roles=None, description=''
|
self,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri,
|
||||||
|
allowed_scopes=None,
|
||||||
|
description='',
|
||||||
):
|
):
|
||||||
"""Add a client
|
"""Add a client
|
||||||
|
|
||||||
@@ -646,12 +651,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
|||||||
app_log.info(f'Creating oauth client {client_id}')
|
app_log.info(f'Creating oauth client {client_id}')
|
||||||
else:
|
else:
|
||||||
app_log.info(f'Updating oauth client {client_id}')
|
app_log.info(f'Updating oauth client {client_id}')
|
||||||
if allowed_roles == None:
|
if allowed_scopes == None:
|
||||||
allowed_roles = []
|
allowed_scopes = []
|
||||||
orm_client.secret = hash_token(client_secret) if client_secret else ""
|
orm_client.secret = hash_token(client_secret) if client_secret else ""
|
||||||
orm_client.redirect_uri = redirect_uri
|
orm_client.redirect_uri = redirect_uri
|
||||||
orm_client.description = description or client_id
|
orm_client.description = description or client_id
|
||||||
orm_client.allowed_roles = allowed_roles
|
orm_client.allowed_scopes = list(allowed_scopes)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return orm_client
|
return orm_client
|
||||||
|
|
||||||
|
@@ -39,7 +39,6 @@ from sqlalchemy.sql.expression import bindparam
|
|||||||
from sqlalchemy.types import LargeBinary, Text, TypeDecorator
|
from sqlalchemy.types import LargeBinary, Text, TypeDecorator
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
|
|
||||||
from .roles import roles_to_scopes
|
|
||||||
from .utils import compare_token, hash_token, new_token, random_port
|
from .utils import compare_token, hash_token, new_token, random_port
|
||||||
|
|
||||||
# top-level variable for easier mocking in tests
|
# top-level variable for easier mocking in tests
|
||||||
@@ -152,7 +151,6 @@ for has_role in (
|
|||||||
'user',
|
'user',
|
||||||
'group',
|
'group',
|
||||||
'service',
|
'service',
|
||||||
'oauth_client',
|
|
||||||
):
|
):
|
||||||
role_map = Table(
|
role_map = Table(
|
||||||
f'{has_role}_role_map',
|
f'{has_role}_role_map',
|
||||||
@@ -697,6 +695,9 @@ class APIToken(Hashed, Base):
|
|||||||
else:
|
else:
|
||||||
cls.check_token(db, token)
|
cls.check_token(db, token)
|
||||||
|
|
||||||
|
# avoid circular import
|
||||||
|
from .roles import roles_to_scopes
|
||||||
|
|
||||||
if scopes is not None and roles is not None:
|
if scopes is not None and roles is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Can only assign one of scopes or roles when creating tokens."
|
"Can only assign one of scopes or roles when creating tokens."
|
||||||
@@ -714,7 +715,7 @@ class APIToken(Hashed, Base):
|
|||||||
# evaluate roles to scopes immediately
|
# evaluate roles to scopes immediately
|
||||||
# TODO: should this be deprecated, or not?
|
# TODO: should this be deprecated, or not?
|
||||||
# warnings.warn(
|
# warnings.warn(
|
||||||
# "Setting roles on tokens is deprecated in JupyterHub 2.2. Use scopes.",
|
# "Setting roles on tokens is deprecated in JupyterHub 3.0. Use scopes.",
|
||||||
# DeprecationWarning,
|
# DeprecationWarning,
|
||||||
# stacklevel=3,
|
# stacklevel=3,
|
||||||
# )
|
# )
|
||||||
@@ -827,9 +828,9 @@ class OAuthClient(Base):
|
|||||||
)
|
)
|
||||||
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
||||||
|
|
||||||
# these are the roles an oauth client is allowed to request
|
# these are the scopes an oauth client is allowed to request
|
||||||
# *not* the roles of the client itself
|
# *not* the scopes of the client itself
|
||||||
allowed_roles = relationship('Role', secondary='oauth_client_role_map')
|
allowed_scopes = Column(JSONList, default=[])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__}(identifier={self.identifier!r})>"
|
return f"<{self.__class__.__name__}(identifier={self.identifier!r})>"
|
||||||
|
@@ -346,19 +346,30 @@ class HubAuth(SingletonConfigurable):
|
|||||||
def _default_cache(self):
|
def _default_cache(self):
|
||||||
return _ExpiringDict(self.cache_max_age)
|
return _ExpiringDict(self.cache_max_age)
|
||||||
|
|
||||||
oauth_scopes = Set(
|
@property
|
||||||
|
def oauth_scopes(self):
|
||||||
|
warnings.warn(
|
||||||
|
"HubAuth.oauth_scopes is deprecated in JupyterHub 3.0. Use .access_scopes"
|
||||||
|
)
|
||||||
|
return self.access_scopes
|
||||||
|
|
||||||
|
access_scopes = Set(
|
||||||
Unicode(),
|
Unicode(),
|
||||||
help="""OAuth scopes to use for allowing access.
|
help="""OAuth scopes to use for allowing access.
|
||||||
|
|
||||||
Get from $JUPYTERHUB_OAUTH_SCOPES by default.
|
Get from $JUPYTERHUB_OAUTH_ACCESS_SCOPES by default.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@default('oauth_scopes')
|
@default('access_scopes')
|
||||||
def _default_scopes(self):
|
def _default_scopes(self):
|
||||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
env_scopes = os.getenv('JUPYTERHUB_OAUTH_ACCESS_SCOPES')
|
||||||
|
if not env_scopes:
|
||||||
|
# deprecated name (since 3.0)
|
||||||
|
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
||||||
if env_scopes:
|
if env_scopes:
|
||||||
return set(json.loads(env_scopes))
|
return set(json.loads(env_scopes))
|
||||||
|
# scopes not specified, use service name if defined
|
||||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||||
if service_name:
|
if service_name:
|
||||||
return {f'access:services!service={service_name}'}
|
return {f'access:services!service={service_name}'}
|
||||||
@@ -954,8 +965,8 @@ class HubAuthenticated:
|
|||||||
|
|
||||||
- .hub_auth: A HubAuth instance
|
- .hub_auth: A HubAuth instance
|
||||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||||
Default comes from .hub_auth.oauth_scopes,
|
Default comes from .hub_auth.oauth_access_scopes,
|
||||||
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
which in turn is set by $JUPYTERHUB_OAUTH_ACCESS_SCOPES
|
||||||
Default values include:
|
Default values include:
|
||||||
- 'access:services', 'access:services!service={service_name}' for services
|
- 'access:services', 'access:services!service={service_name}' for services
|
||||||
- 'access:servers', 'access:servers!user={user}',
|
- 'access:servers', 'access:servers!user={user}',
|
||||||
@@ -994,8 +1005,8 @@ class HubAuthenticated:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def hub_scopes(self):
|
def hub_scopes(self):
|
||||||
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
|
"""Set of allowed scopes (use hub_auth.access_scopes by default)"""
|
||||||
return self.hub_auth.oauth_scopes or None
|
return self.hub_auth.access_scopes or None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allow_all(self):
|
def allow_all(self):
|
||||||
|
@@ -102,8 +102,8 @@ class _ServiceSpawner(LocalProcessSpawner):
|
|||||||
cmd = Command(minlen=0)
|
cmd = Command(minlen=0)
|
||||||
_service_name = Unicode()
|
_service_name = Unicode()
|
||||||
|
|
||||||
@default("oauth_scopes")
|
@default("oauth_access_scopes")
|
||||||
def _default_oauth_scopes(self):
|
def _default_oauth_access_scopes(self):
|
||||||
return [
|
return [
|
||||||
"access:services",
|
"access:services",
|
||||||
f"access:services!service={self._service_name}",
|
f"access:services!service={self._service_name}",
|
||||||
@@ -203,7 +203,14 @@ class Service(LoggingConfigurable):
|
|||||||
oauth_roles = List(
|
oauth_roles = List(
|
||||||
help="""OAuth allowed roles.
|
help="""OAuth allowed roles.
|
||||||
|
|
||||||
This sets the maximum and default roles
|
DEPRECATED in 3.0: use oauth_client_allowed_scopes
|
||||||
|
"""
|
||||||
|
).tag(input=True)
|
||||||
|
|
||||||
|
oauth_client_allowed_scopes = List(
|
||||||
|
help="""OAuth allowed scopes.
|
||||||
|
|
||||||
|
This sets the maximum and default scopes
|
||||||
assigned to oauth tokens issued for this service
|
assigned to oauth tokens issued for this service
|
||||||
(i.e. tokens stored in browsers after authenticating with the server),
|
(i.e. tokens stored in browsers after authenticating with the server),
|
||||||
defining what actions the service can take on behalf of logged-in users.
|
defining what actions the service can take on behalf of logged-in users.
|
||||||
|
@@ -36,7 +36,9 @@ from traitlets import (
|
|||||||
)
|
)
|
||||||
from traitlets.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
|
|
||||||
|
from . import orm
|
||||||
from .objects import Server
|
from .objects import Server
|
||||||
|
from .roles import roles_to_scopes
|
||||||
from .traitlets import ByteSpecification, Callable, Command
|
from .traitlets import ByteSpecification, Callable, Command
|
||||||
from .utils import (
|
from .utils import (
|
||||||
AnyTimeoutError,
|
AnyTimeoutError,
|
||||||
@@ -274,8 +276,25 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
oauth_scopes = List(Unicode())
|
oauth_scopes = List(Unicode())
|
||||||
|
|
||||||
@default("oauth_scopes")
|
@property
|
||||||
def _default_oauth_scopes(self):
|
def oauth_scopes(self):
|
||||||
|
warnings.warn(
|
||||||
|
"""Spawner.oauth_scopes is deprecated in JupyterHub 2.3.
|
||||||
|
|
||||||
|
Use Spawner.oauth_access_scopes
|
||||||
|
""",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return self.oauth_access_scopes
|
||||||
|
|
||||||
|
oauth_access_scopes = List(
|
||||||
|
Unicode(),
|
||||||
|
help="""The scope(s) needed to access this server""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@default("oauth_access_scopes")
|
||||||
|
def _default_access_scopes(self):
|
||||||
return [
|
return [
|
||||||
f"access:servers!server={self.user.name}/{self.name}",
|
f"access:servers!server={self.user.name}/{self.name}",
|
||||||
f"access:servers!user={self.user.name}",
|
f"access:servers!user={self.user.name}",
|
||||||
@@ -287,6 +306,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
[Callable(), List()],
|
[Callable(), List()],
|
||||||
help="""Allowed roles for oauth tokens.
|
help="""Allowed roles for oauth tokens.
|
||||||
|
|
||||||
|
Deprecated in 3.0: use oauth_client_allowed_scopes
|
||||||
|
|
||||||
This sets the maximum and default roles
|
This sets the maximum and default roles
|
||||||
assigned to oauth tokens issued by a single-user server's
|
assigned to oauth tokens issued by a single-user server's
|
||||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||||
@@ -294,9 +315,73 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Default is an empty list, meaning minimal permissions to identify users,
|
Default is an empty list, meaning minimal permissions to identify users,
|
||||||
no actions can be taken on their behalf.
|
no actions can be taken on their behalf.
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
oauth_client_allowed_scopes = Union(
|
||||||
|
[Callable(), List()],
|
||||||
|
help="""Allowed scopes for oauth tokens issued by this server's oauth client.
|
||||||
|
|
||||||
|
This sets the maximum and default scopes
|
||||||
|
assigned to oauth tokens issued by a single-user server's
|
||||||
|
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||||
|
defining what actions the server can take on behalf of logged-in users.
|
||||||
|
|
||||||
|
Default is an empty list, meaning minimal permissions to identify users,
|
||||||
|
no actions can be taken on their behalf.
|
||||||
|
|
||||||
|
If callable, will be called with the Spawner as a single argument.
|
||||||
|
Callables may be async.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
async def _get_oauth_client_allowed_scopes(self):
|
||||||
|
"""Private method: get oauth allowed scopes
|
||||||
|
|
||||||
|
Handle:
|
||||||
|
|
||||||
|
- oauth_client_allowed_scopes
|
||||||
|
- callable config
|
||||||
|
- deprecated oauth_roles config
|
||||||
|
- access_scopes
|
||||||
|
"""
|
||||||
|
# cases:
|
||||||
|
# 1. only scopes
|
||||||
|
# 2. only roles
|
||||||
|
# 3. both! (conflict, favor scopes)
|
||||||
|
scopes = []
|
||||||
|
if self.oauth_client_allowed_scopes:
|
||||||
|
allowed_scopes = self.oauth_client_allowed_scopes
|
||||||
|
if callable(allowed_scopes):
|
||||||
|
allowed_scopes = allowed_scopes(self)
|
||||||
|
if inspect.isawaitable(allowed_scopes):
|
||||||
|
allowed_scopes = await allowed_scopes
|
||||||
|
scopes.extend(allowed_scopes)
|
||||||
|
|
||||||
|
if self.oauth_roles:
|
||||||
|
if scopes:
|
||||||
|
# both defined! Warn
|
||||||
|
warnings.warn(
|
||||||
|
f"Ignoring deprecated Spawner.oauth_roles={self.oauth_roles} in favor of Spawner.oauth_client_allowed_scopes.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
role_names = self.oauth_roles
|
||||||
|
if callable(role_names):
|
||||||
|
role_names = role_names(self)
|
||||||
|
roles = list(
|
||||||
|
self.db.query(orm.Role).filter(orm.Role.name.in_(role_names))
|
||||||
|
)
|
||||||
|
if len(roles) != len(role_names):
|
||||||
|
missing_roles = set(role_names).difference(
|
||||||
|
{role.name for role in roles}
|
||||||
|
)
|
||||||
|
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
|
||||||
|
scopes.extend(roles_to_scopes(roles))
|
||||||
|
|
||||||
|
# always add access scopes
|
||||||
|
scopes.extend(self.oauth_access_scopes)
|
||||||
|
return sorted(set(scopes))
|
||||||
|
|
||||||
will_resume = Bool(
|
will_resume = Bool(
|
||||||
False,
|
False,
|
||||||
help="""Whether the Spawner will resume on next start
|
help="""Whether the Spawner will resume on next start
|
||||||
@@ -875,7 +960,14 @@ class Spawner(LoggingConfigurable):
|
|||||||
self.user.url, url_escape_path(self.name), 'oauth_callback'
|
self.user.url, url_escape_path(self.name), 'oauth_callback'
|
||||||
)
|
)
|
||||||
|
|
||||||
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
|
# deprecated env, renamed in 3.0 for disambiguation
|
||||||
|
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_access_scopes)
|
||||||
|
env['JUPYTERHUB_OAUTH_ACCESS_SCOPES'] = json.dumps(self.oauth_access_scopes)
|
||||||
|
|
||||||
|
# added in 3.0
|
||||||
|
env['JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES'] = json.dumps(
|
||||||
|
self.oauth_client_allowed_scopes
|
||||||
|
)
|
||||||
|
|
||||||
# Info previously passed on args
|
# Info previously passed on args
|
||||||
env['JUPYTERHUB_USER'] = self.user.name
|
env['JUPYTERHUB_USER'] = self.user.name
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for named servers"""
|
"""Tests for named servers"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import unquote, urlencode, urlparse
|
from urllib.parse import unquote, urlencode, urlparse
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ async def test_default_server(app, named_servers):
|
|||||||
'url': user.url,
|
'url': user.url,
|
||||||
'pending': None,
|
'pending': None,
|
||||||
'ready': True,
|
'ready': True,
|
||||||
|
'stopped': False,
|
||||||
'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
|
'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
|
||||||
username
|
username
|
||||||
),
|
),
|
||||||
@@ -148,6 +150,7 @@ async def test_create_named_server(
|
|||||||
'url': url_path_join(user.url, escapedname, '/'),
|
'url': url_path_join(user.url, escapedname, '/'),
|
||||||
'pending': None,
|
'pending': None,
|
||||||
'ready': True,
|
'ready': True,
|
||||||
|
'stopped': False,
|
||||||
'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
|
'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
|
||||||
username, escapedname
|
username, escapedname
|
||||||
),
|
),
|
||||||
@@ -433,3 +436,61 @@ async def test_named_server_stop_server(app, username, named_servers):
|
|||||||
assert user.spawners[server_name].server is None
|
assert user.spawners[server_name].server is None
|
||||||
assert user.spawners[''].server
|
assert user.spawners[''].server
|
||||||
assert user.running
|
assert user.running
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"include_stopped_servers",
|
||||||
|
[True, False],
|
||||||
|
)
|
||||||
|
async def test_stopped_servers(app, user, named_servers, include_stopped_servers):
|
||||||
|
r = await api_request(app, 'users', user.name, 'server', method='post')
|
||||||
|
r.raise_for_status()
|
||||||
|
r = await api_request(app, 'users', user.name, 'servers', "named", method='post')
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# wait for starts
|
||||||
|
for i in range(60):
|
||||||
|
r = await api_request(app, 'users', user.name)
|
||||||
|
r.raise_for_status()
|
||||||
|
user_model = r.json()
|
||||||
|
if not all(s["ready"] for s in user_model["servers"].values()):
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise TimeoutError(f"User never stopped: {user_model}")
|
||||||
|
|
||||||
|
r = await api_request(app, 'users', user.name, 'server', method='delete')
|
||||||
|
r.raise_for_status()
|
||||||
|
r = await api_request(app, 'users', user.name, 'servers', "named", method='delete')
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# wait for stops
|
||||||
|
for i in range(60):
|
||||||
|
r = await api_request(app, 'users', user.name)
|
||||||
|
r.raise_for_status()
|
||||||
|
user_model = r.json()
|
||||||
|
if not all(s["stopped"] for s in user_model["servers"].values()):
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise TimeoutError(f"User never stopped: {user_model}")
|
||||||
|
|
||||||
|
# we have two stopped servers
|
||||||
|
path = f"users/{user.name}"
|
||||||
|
if include_stopped_servers:
|
||||||
|
path = f"{path}?include_stopped_servers"
|
||||||
|
r = await api_request(app, path)
|
||||||
|
r.raise_for_status()
|
||||||
|
user_model = r.json()
|
||||||
|
servers = list(user_model["servers"].values())
|
||||||
|
if include_stopped_servers:
|
||||||
|
assert len(servers) == 2
|
||||||
|
assert all(s["last_activity"] for s in servers)
|
||||||
|
assert all(s["started"] is None for s in servers)
|
||||||
|
assert all(s["stopped"] for s in servers)
|
||||||
|
assert not any(s["ready"] for s in servers)
|
||||||
|
assert not any(s["pending"] for s in servers)
|
||||||
|
else:
|
||||||
|
assert user_model["servers"] == {}
|
||||||
|
@@ -1153,7 +1153,7 @@ async def test_oauth_page_scope_appearance(
|
|||||||
.filter_by(identifier=service.oauth_client_id)
|
.filter_by(identifier=service.oauth_client_id)
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
oauth_client.allowed_roles = [service_role]
|
oauth_client.allowed_scopes = sorted(roles.roles_to_scopes([service_role]))
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
|
|
||||||
s = AsyncSession()
|
s = AsyncSession()
|
||||||
|
@@ -894,20 +894,18 @@ async def test_server_role_api_calls(
|
|||||||
assert r.status_code == response
|
assert r.status_code == response
|
||||||
|
|
||||||
|
|
||||||
async def test_oauth_allowed_roles(app, create_temp_role):
|
async def test_oauth_client_allowed_scopes(app):
|
||||||
allowed_roles = ['oracle', 'goose']
|
allowed_scopes = ['read:users', 'read:groups']
|
||||||
service = {
|
service = {
|
||||||
'name': 'oas1',
|
'name': 'oas1',
|
||||||
'api_token': 'some-token',
|
'api_token': 'some-token',
|
||||||
'oauth_roles': ['oracle', 'goose'],
|
'oauth_client_allowed_scopes': allowed_scopes,
|
||||||
}
|
}
|
||||||
for role in allowed_roles:
|
|
||||||
create_temp_role('read:users', role_name=role)
|
|
||||||
app.services.append(service)
|
app.services.append(service)
|
||||||
app.init_services()
|
app.init_services()
|
||||||
app_service = app.services[0]
|
app_service = app.services[0]
|
||||||
assert app_service['name'] == 'oas1'
|
assert app_service['name'] == 'oas1'
|
||||||
assert set(app_service['oauth_roles']) == set(allowed_roles)
|
assert set(app_service['oauth_client_allowed_scopes']) == set(allowed_scopes)
|
||||||
|
|
||||||
|
|
||||||
async def test_user_group_roles(app, create_temp_role):
|
async def test_user_group_roles(app, create_temp_role):
|
||||||
|
@@ -7,7 +7,7 @@ from subprocess import Popen
|
|||||||
from async_generator import asynccontextmanager
|
from async_generator import asynccontextmanager
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..roles import update_roles
|
from ..roles import roles_to_scopes
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
exponential_backoff,
|
exponential_backoff,
|
||||||
maybe_future,
|
maybe_future,
|
||||||
@@ -97,7 +97,10 @@ async def test_external_service(app):
|
|||||||
service = app._service_map[name]
|
service = app._service_map[name]
|
||||||
assert service.oauth_available
|
assert service.oauth_available
|
||||||
assert service.oauth_client is not None
|
assert service.oauth_client is not None
|
||||||
assert service.oauth_client.allowed_roles == [orm.Role.find(app.db, "user")]
|
assert set(service.oauth_client.allowed_scopes) == {
|
||||||
|
"self",
|
||||||
|
f"access:services!service={name}",
|
||||||
|
}
|
||||||
api_token = service.orm.api_tokens[0]
|
api_token = service.orm.api_tokens[0]
|
||||||
url = public_url(app, service) + '/api/users'
|
url = public_url(app, service) + '/api/users'
|
||||||
r = await async_requests.get(url, allow_redirects=False)
|
r = await async_requests.get(url, allow_redirects=False)
|
||||||
|
@@ -4,7 +4,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs, quote, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -13,6 +13,7 @@ from tornado.httputil import url_concat
|
|||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
|
|
||||||
from .. import orm, roles, scopes
|
from .. import orm, roles, scopes
|
||||||
|
from ..roles import roles_to_scopes
|
||||||
from ..services.auth import _ExpiringDict
|
from ..services.auth import _ExpiringDict
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import public_url
|
from .mocking import public_url
|
||||||
@@ -292,9 +293,11 @@ async def test_oauth_service_roles(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
oauth_client.allowed_roles = [
|
oauth_client.allowed_scopes = sorted(
|
||||||
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
|
roles_to_scopes(
|
||||||
]
|
[orm.Role.find(app.db, role_name) for role_name in client_allowed_roles]
|
||||||
|
)
|
||||||
|
)
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||||
if request_scopes:
|
if request_scopes:
|
||||||
@@ -486,7 +489,7 @@ async def test_oauth_page_hit(
|
|||||||
.filter_by(identifier=service.oauth_client_id)
|
.filter_by(identifier=service.oauth_client_id)
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
oauth_client.allowed_roles = list(test_roles.values())
|
oauth_client.allowed_scopes = sorted(roles_to_scopes(list(test_roles.values())))
|
||||||
|
|
||||||
authorized_scopes = roles.roles_to_scopes([test_roles[t] for t in token_roles])
|
authorized_scopes = roles.roles_to_scopes([test_roles[t] for t in token_roles])
|
||||||
authorized_scopes.update(scopes.identify_scopes())
|
authorized_scopes.update(scopes.identify_scopes())
|
||||||
|
@@ -436,14 +436,16 @@ async def test_hub_connect_url(db):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_spawner_oauth_roles(app, user):
|
async def test_spawner_oauth_scopes(app, user):
|
||||||
allowed_roles = ["admin", "user"]
|
allowed_scopes = ["read:users"]
|
||||||
spawner = user.spawners['']
|
spawner = user.spawners['']
|
||||||
spawner.oauth_roles = allowed_roles
|
spawner.oauth_client_allowed_scopes = allowed_scopes
|
||||||
# exercise start/stop which assign roles to oauth client
|
# exercise start/stop which assign roles to oauth client
|
||||||
await spawner.user.spawn()
|
await spawner.user.spawn()
|
||||||
oauth_client = spawner.orm_spawner.oauth_client
|
oauth_client = spawner.orm_spawner.oauth_client
|
||||||
assert sorted(role.name for role in oauth_client.allowed_roles) == allowed_roles
|
assert sorted(oauth_client.allowed_scopes) == sorted(
|
||||||
|
allowed_scopes + spawner.oauth_access_scopes
|
||||||
|
)
|
||||||
await spawner.user.stop()
|
await spawner.user.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@@ -702,28 +702,12 @@ class User:
|
|||||||
client_id = spawner.oauth_client_id
|
client_id = spawner.oauth_client_id
|
||||||
oauth_provider = self.settings.get('oauth_provider')
|
oauth_provider = self.settings.get('oauth_provider')
|
||||||
if oauth_provider:
|
if oauth_provider:
|
||||||
allowed_roles = spawner.oauth_roles
|
allowed_scopes = await spawner._get_oauth_client_allowed_scopes()
|
||||||
if callable(allowed_roles):
|
|
||||||
allowed_roles = allowed_roles(spawner)
|
|
||||||
|
|
||||||
# allowed_roles config is a list of strings
|
|
||||||
# oauth provider.allowed_roles is a list of orm.Roles
|
|
||||||
if allowed_roles:
|
|
||||||
allowed_role_names = allowed_roles
|
|
||||||
allowed_roles = list(
|
|
||||||
self.db.query(orm.Role).filter(orm.Role.name.in_(allowed_roles))
|
|
||||||
)
|
|
||||||
if len(allowed_roles) != len(allowed_role_names):
|
|
||||||
missing_roles = set(allowed_role_names).difference(
|
|
||||||
{role.name for role in allowed_roles}
|
|
||||||
)
|
|
||||||
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
|
|
||||||
|
|
||||||
oauth_client = oauth_provider.add_client(
|
oauth_client = oauth_provider.add_client(
|
||||||
client_id,
|
client_id,
|
||||||
api_token,
|
api_token,
|
||||||
url_path_join(self.url, url_escape_path(server_name), 'oauth_callback'),
|
url_path_join(self.url, url_escape_path(server_name), 'oauth_callback'),
|
||||||
allowed_roles=allowed_roles,
|
allowed_scopes=allowed_scopes,
|
||||||
description="Server at %s"
|
description="Server at %s"
|
||||||
% (url_path_join(self.base_url, server_name) + '/'),
|
% (url_path_join(self.base_url, server_name) + '/'),
|
||||||
)
|
)
|
||||||
|
@@ -17,7 +17,7 @@ target_version = [
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "2.4.0.dev"
|
current = "3.0.0b1"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
Reference in New Issue
Block a user