mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 11:33:01 +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
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
version: 2.4.0.dev
|
||||
version: 3.0.0b1
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
@@ -139,6 +139,16 @@ paths:
|
||||
If unspecified, use api_page_default_limit.
|
||||
schema:
|
||||
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:
|
||||
200:
|
||||
description: The Hub's user list
|
||||
@@ -564,7 +574,7 @@ paths:
|
||||
A list of role names from which to derive scopes.
|
||||
This is a shortcut for assigning collections of scopes;
|
||||
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.)
|
||||
items:
|
||||
type: string
|
||||
@@ -572,7 +582,7 @@ paths:
|
||||
type: array
|
||||
description: |
|
||||
A list of scopes that the token should have.
|
||||
(new in JupyterHub 2.3).
|
||||
(new in JupyterHub 3.0).
|
||||
items:
|
||||
type: string
|
||||
required: false
|
||||
@@ -1160,7 +1170,11 @@ components:
|
||||
format: date-time
|
||||
servers:
|
||||
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:
|
||||
$ref: "#/components/schemas/Server"
|
||||
auth_state:
|
||||
@@ -1182,6 +1196,15 @@ components:
|
||||
description: |
|
||||
Whether the server is ready for traffic.
|
||||
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:
|
||||
type: string
|
||||
description: |
|
||||
@@ -1327,15 +1350,15 @@ components:
|
||||
roles:
|
||||
type: array
|
||||
description:
|
||||
Deprecated in JupyterHub 2.3, always an empty list. Tokens
|
||||
have 'scopes' starting from JupyterHub 2.3.
|
||||
Deprecated in JupyterHub 3, always an empty list. Tokens have
|
||||
'scopes' starting from JupyterHub 3.
|
||||
items:
|
||||
type: string
|
||||
scopes:
|
||||
type: array
|
||||
description:
|
||||
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:
|
||||
type: string
|
||||
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.
|
||||
release = jupyterhub.__version__
|
||||
|
||||
language = None
|
||||
language = "en"
|
||||
exclude_patterns = []
|
||||
pygments_style = 'sphinx'
|
||||
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
|
||||
|
||||
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
|
||||
- Services
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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)=
|
||||
|
||||
|
@@ -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.
|
||||
|
||||
(self-referencing-filters)=
|
||||
|
||||
### Self-referencing 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.
|
||||
|
||||
:::{versionadded} 2.3
|
||||
:::{versionadded} 3.0
|
||||
`!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}
|
||||
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.
|
||||
```
|
||||
|
||||
(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.
|
||||
|
||||
% 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
|
||||
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
|
||||
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** 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.
|
||||
|
||||
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 with specific roles
|
||||
### Requesting API token with specific scopes
|
||||
|
||||
:::{versionchanged} 2.3
|
||||
:::{versionchanged} 3.0
|
||||
API tokens have _scopes_ instead of roles,
|
||||
so that their permissions cannot be updated.
|
||||
|
||||
You may still request roles for a token,
|
||||
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.
|
||||
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).
|
||||
|
||||
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, 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.
|
||||
The API request is resolved without additional errors using the scope _intersection_,
|
||||
but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
|
||||
If, due to modifications of permissions of the token or token owner,
|
||||
at API request time a token has any scopes that its owner does not,
|
||||
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.
|
||||
|
||||
@@ -67,9 +75,9 @@ Figure 1. Resolving roles and scopes during API token 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.
|
||||
|
||||
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_URL: Local URL where the service is expected to be listening.
|
||||
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
|
||||
@@ -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.
|
||||
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.
|
||||
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
|
||||
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.
|
||||
- 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_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:
|
||||
|
||||
|
@@ -7,7 +7,10 @@ c.JupyterHub.services = [
|
||||
'name': 'grades',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'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
|
||||
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,
|
||||
which will provide the username and current scopes.
|
||||
|
||||
|
@@ -14,11 +14,11 @@ c.JupyterHub.services = [
|
||||
# only requesting access to the service,
|
||||
# and identification by name,
|
||||
# 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,
|
||||
# or the ability to take actions on users' behalf, as required.
|
||||
# The default 'token' role has the full permissions of its owner:
|
||||
# 'oauth_roles': ['token'],
|
||||
# the 'inherit' scope means the full permissions of the owner
|
||||
# 'oauth_client_allowed_scopes': ['inherit'],
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -28,17 +28,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"css-loader": "^5.0.1",
|
||||
"eslint-plugin-unused-imports": "^1.1.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"history": "^5.0.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"prop-types": "^15.7.2",
|
||||
@@ -51,24 +41,35 @@
|
||||
"react-redux": "^7.2.2",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"recompose": "^0.30.0",
|
||||
"recompose": "npm:react-recompose@^0.31.2",
|
||||
"redux": "^4.0.5",
|
||||
"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"
|
||||
"regenerator-runtime": "^0.13.9"
|
||||
},
|
||||
"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",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-loader": "^8.2.1",
|
||||
"css-loader": "^5.0.1",
|
||||
"enzyme": "^3.11.0",
|
||||
"eslint": "^7.18.0",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-unused-imports": "^1.1.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"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 = () => {
|
||||
useEffect(() => {
|
||||
let { limit, user_page, groups_page } = initialState;
|
||||
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
|
||||
.then((data) => data.json())
|
||||
let api = withAPI()().props;
|
||||
api
|
||||
.updateUsers(user_page * limit, limit)
|
||||
.then((data) =>
|
||||
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
|
||||
)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
|
||||
.then((data) => data.json())
|
||||
api
|
||||
.updateGroups(groups_page * limit, limit)
|
||||
.then((data) =>
|
||||
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
||||
)
|
||||
|
@@ -201,6 +201,25 @@ const ServerDashboard = (props) => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<ReactObjectTableViewer
|
||||
className="table-striped table-bordered"
|
||||
@@ -214,7 +233,7 @@ const ServerDashboard = (props) => {
|
||||
valueStyle={{
|
||||
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-server">
|
||||
{server.name ? (
|
||||
<p className="text-secondary">{server.name}</p>
|
||||
) : (
|
||||
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
||||
)}
|
||||
</td>
|
||||
<td data-testid="user-row-last-activity">
|
||||
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||
|
@@ -4,7 +4,9 @@ import { jhapiRequest } from "./jhapiUtil";
|
||||
const withAPI = withProps(() => ({
|
||||
updateUsers: (offset, limit, name_filter) =>
|
||||
jhapiRequest(
|
||||
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`,
|
||||
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
|
||||
name_filter || ""
|
||||
}`,
|
||||
"GET"
|
||||
).then((data) => data.json()),
|
||||
updateGroups: (offset, limit) =>
|
||||
|
@@ -1,6 +1,5 @@
|
||||
const webpack = require("webpack");
|
||||
const path = require("path");
|
||||
const express = require("express");
|
||||
|
||||
module.exports = {
|
||||
entry: path.resolve(__dirname, "src", "App.jsx"),
|
||||
@@ -34,16 +33,19 @@ module.exports = {
|
||||
},
|
||||
plugins: [new webpack.HotModuleReplacementPlugin()],
|
||||
devServer: {
|
||||
contentBase: path.resolve(__dirname, "build"),
|
||||
static: {
|
||||
directory: path.resolve(__dirname, "build"),
|
||||
},
|
||||
port: 9000,
|
||||
before: (app, server) => {
|
||||
onBeforeSetupMiddleware: (devServer) => {
|
||||
const app = devServer.app;
|
||||
|
||||
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":{}}]'
|
||||
);
|
||||
var group_data = JSON.parse(
|
||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
||||
);
|
||||
app.use(express.json());
|
||||
|
||||
// get user_data
|
||||
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.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
# 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
|
||||
# 0.1.0rc1
|
||||
|
@@ -13,12 +13,11 @@ depends_on = None
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, ForeignKey, Integer, Table, Unicode
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from jupyterhub import orm, roles
|
||||
from jupyterhub import orm, roles, scopes
|
||||
|
||||
|
||||
def upgrade():
|
||||
@@ -33,10 +32,10 @@ def upgrade():
|
||||
if 'oauth_codes' in tables:
|
||||
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
|
||||
# no migration to do
|
||||
return
|
||||
# in which case no migration to do
|
||||
|
||||
# define new scopes column on API tokens
|
||||
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
|
||||
@@ -69,9 +68,48 @@ def upgrade():
|
||||
# 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)
|
||||
)
|
||||
|
||||
if 'oauth_client_role_map' in tables:
|
||||
# redefine the to-be-removed api_token->role relationship
|
||||
# so we can run a query on it for the migration
|
||||
client_role_map = Table(
|
||||
"oauth_client_role_map",
|
||||
orm.Base.metadata,
|
||||
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():
|
||||
# cannot map permissions from scopes back to roles
|
||||
# drop whole api token table (revokes all tokens), which will be recreated on hub start
|
||||
op.drop_table('api_tokens')
|
||||
op.drop_table('oauth_clients')
|
||||
op.drop_table('oauth_codes')
|
||||
|
@@ -187,22 +187,44 @@ class APIHandler(BaseHandler):
|
||||
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
|
||||
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 = {
|
||||
'name': spawner.name,
|
||||
'last_activity': isoformat(spawner.orm_spawner.last_activity),
|
||||
'started': isoformat(spawner.orm_spawner.started),
|
||||
'pending': spawner.pending,
|
||||
'ready': spawner.ready,
|
||||
'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'),
|
||||
'name': orm_spawner.name,
|
||||
'last_activity': isoformat(orm_spawner.last_activity),
|
||||
'started': isoformat(orm_spawner.started),
|
||||
'pending': pending,
|
||||
'ready': ready,
|
||||
'stopped': stopped,
|
||||
'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
|
||||
'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')
|
||||
if scope_filter(spawner, kind='server'):
|
||||
model['state'] = spawner.get_state()
|
||||
model['state'] = state
|
||||
return model
|
||||
|
||||
def token_model(self, token):
|
||||
@@ -248,10 +270,22 @@ class APIHandler(BaseHandler):
|
||||
keys.update(allowed_keys)
|
||||
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):
|
||||
"""Get the JSON model for a User object"""
|
||||
if isinstance(user, orm.User):
|
||||
user = self.users[user.id]
|
||||
include_stopped_servers = self.include_stopped_servers
|
||||
model = {
|
||||
'kind': 'user',
|
||||
'name': user.name,
|
||||
@@ -291,18 +325,29 @@ class APIHandler(BaseHandler):
|
||||
if '' in user.spawners and 'pending' in allowed_keys:
|
||||
model['pending'] = user.spawners[''].pending
|
||||
|
||||
servers = model['servers'] = {}
|
||||
servers = {}
|
||||
scope_filter = self.get_scope_filter('read:servers')
|
||||
for name, spawner in user.spawners.items():
|
||||
# include 'active' servers, not just ready
|
||||
# (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)
|
||||
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
|
||||
# leave present and empty
|
||||
# if request has access to read servers in general
|
||||
model.pop('servers')
|
||||
model["servers"] = servers
|
||||
|
||||
return model
|
||||
|
||||
def group_model(self, group):
|
||||
|
@@ -2378,21 +2378,34 @@ class JupyterHub(Application):
|
||||
service.orm.server = None
|
||||
|
||||
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 not allowed_scopes:
|
||||
# DEPRECATED? It's still convenient and valid,
|
||||
# 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(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
allowed_roles=allowed_roles,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
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:
|
||||
if service.oauth_client:
|
||||
self.db.delete(service.oauth_client)
|
||||
|
@@ -151,7 +151,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
)
|
||||
if orm_client is None:
|
||||
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:
|
||||
# add identify-user scope
|
||||
# and access-service scope
|
||||
@@ -569,8 +569,8 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
requested_scopes.discard("identify")
|
||||
|
||||
# TODO: handle roles->scopes transition
|
||||
# In 2.0-2.2, `?scopes=` only accepted _role_ names,
|
||||
# but in 2.3 we accept and prefer scopes.
|
||||
# In 2.x, `?scopes=` only accepted _role_ names,
|
||||
# but in 3.0 we accept and prefer scopes.
|
||||
# For backward-compatibility, we still accept both.
|
||||
# Should roles be deprecated here, or kept as a convenience?
|
||||
try:
|
||||
@@ -589,7 +589,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
)
|
||||
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
|
||||
# and accessing the service itself
|
||||
@@ -624,7 +624,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
super().__init__(validator, *args, **kwargs)
|
||||
|
||||
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
|
||||
|
||||
@@ -646,12 +651,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
app_log.info(f'Creating oauth client {client_id}')
|
||||
else:
|
||||
app_log.info(f'Updating oauth client {client_id}')
|
||||
if allowed_roles == None:
|
||||
allowed_roles = []
|
||||
if allowed_scopes == None:
|
||||
allowed_scopes = []
|
||||
orm_client.secret = hash_token(client_secret) if client_secret else ""
|
||||
orm_client.redirect_uri = redirect_uri
|
||||
orm_client.description = description or client_id
|
||||
orm_client.allowed_roles = allowed_roles
|
||||
orm_client.allowed_scopes = list(allowed_scopes)
|
||||
self.db.commit()
|
||||
return orm_client
|
||||
|
||||
|
@@ -39,7 +39,6 @@ from sqlalchemy.sql.expression import bindparam
|
||||
from sqlalchemy.types import LargeBinary, Text, TypeDecorator
|
||||
from tornado.log import app_log
|
||||
|
||||
from .roles import roles_to_scopes
|
||||
from .utils import compare_token, hash_token, new_token, random_port
|
||||
|
||||
# top-level variable for easier mocking in tests
|
||||
@@ -152,7 +151,6 @@ for has_role in (
|
||||
'user',
|
||||
'group',
|
||||
'service',
|
||||
'oauth_client',
|
||||
):
|
||||
role_map = Table(
|
||||
f'{has_role}_role_map',
|
||||
@@ -697,6 +695,9 @@ class APIToken(Hashed, Base):
|
||||
else:
|
||||
cls.check_token(db, token)
|
||||
|
||||
# avoid circular import
|
||||
from .roles import roles_to_scopes
|
||||
|
||||
if scopes is not None and roles is not None:
|
||||
raise ValueError(
|
||||
"Can only assign one of scopes or roles when creating tokens."
|
||||
@@ -714,7 +715,7 @@ class APIToken(Hashed, Base):
|
||||
# evaluate roles to scopes immediately
|
||||
# TODO: should this be deprecated, or not?
|
||||
# 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,
|
||||
# stacklevel=3,
|
||||
# )
|
||||
@@ -827,9 +828,9 @@ class OAuthClient(Base):
|
||||
)
|
||||
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
||||
|
||||
# these are the roles an oauth client is allowed to request
|
||||
# *not* the roles of the client itself
|
||||
allowed_roles = relationship('Role', secondary='oauth_client_role_map')
|
||||
# these are the scopes an oauth client is allowed to request
|
||||
# *not* the scopes of the client itself
|
||||
allowed_scopes = Column(JSONList, default=[])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}(identifier={self.identifier!r})>"
|
||||
|
@@ -346,19 +346,30 @@ class HubAuth(SingletonConfigurable):
|
||||
def _default_cache(self):
|
||||
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(),
|
||||
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)
|
||||
|
||||
@default('oauth_scopes')
|
||||
@default('access_scopes')
|
||||
def _default_scopes(self):
|
||||
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:
|
||||
return set(json.loads(env_scopes))
|
||||
# scopes not specified, use service name if defined
|
||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||
if service_name:
|
||||
return {f'access:services!service={service_name}'}
|
||||
@@ -954,8 +965,8 @@ class HubAuthenticated:
|
||||
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||
Default comes from .hub_auth.oauth_scopes,
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
||||
Default comes from .hub_auth.oauth_access_scopes,
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_ACCESS_SCOPES
|
||||
Default values include:
|
||||
- 'access:services', 'access:services!service={service_name}' for services
|
||||
- 'access:servers', 'access:servers!user={user}',
|
||||
@@ -994,8 +1005,8 @@ class HubAuthenticated:
|
||||
|
||||
@property
|
||||
def hub_scopes(self):
|
||||
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
|
||||
return self.hub_auth.oauth_scopes or None
|
||||
"""Set of allowed scopes (use hub_auth.access_scopes by default)"""
|
||||
return self.hub_auth.access_scopes or None
|
||||
|
||||
@property
|
||||
def allow_all(self):
|
||||
|
@@ -102,8 +102,8 @@ class _ServiceSpawner(LocalProcessSpawner):
|
||||
cmd = Command(minlen=0)
|
||||
_service_name = Unicode()
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
@default("oauth_access_scopes")
|
||||
def _default_oauth_access_scopes(self):
|
||||
return [
|
||||
"access:services",
|
||||
f"access:services!service={self._service_name}",
|
||||
@@ -203,7 +203,14 @@ class Service(LoggingConfigurable):
|
||||
oauth_roles = List(
|
||||
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
|
||||
(i.e. tokens stored in browsers after authenticating with the server),
|
||||
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 . import orm
|
||||
from .objects import Server
|
||||
from .roles import roles_to_scopes
|
||||
from .traitlets import ByteSpecification, Callable, Command
|
||||
from .utils import (
|
||||
AnyTimeoutError,
|
||||
@@ -274,8 +276,25 @@ class Spawner(LoggingConfigurable):
|
||||
|
||||
oauth_scopes = List(Unicode())
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
@property
|
||||
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 [
|
||||
f"access:servers!server={self.user.name}/{self.name}",
|
||||
f"access:servers!user={self.user.name}",
|
||||
@@ -287,6 +306,8 @@ class Spawner(LoggingConfigurable):
|
||||
[Callable(), List()],
|
||||
help="""Allowed roles for oauth tokens.
|
||||
|
||||
Deprecated in 3.0: use oauth_client_allowed_scopes
|
||||
|
||||
This sets the maximum and default roles
|
||||
assigned to oauth tokens issued by a single-user server's
|
||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||
@@ -297,6 +318,70 @@ class Spawner(LoggingConfigurable):
|
||||
""",
|
||||
).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)
|
||||
|
||||
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(
|
||||
False,
|
||||
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'
|
||||
)
|
||||
|
||||
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
|
||||
env['JUPYTERHUB_USER'] = self.user.name
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Tests for named servers"""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from unittest import mock
|
||||
from urllib.parse import unquote, urlencode, urlparse
|
||||
|
||||
@@ -61,6 +62,7 @@ async def test_default_server(app, named_servers):
|
||||
'url': user.url,
|
||||
'pending': None,
|
||||
'ready': True,
|
||||
'stopped': False,
|
||||
'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
|
||||
username
|
||||
),
|
||||
@@ -148,6 +150,7 @@ async def test_create_named_server(
|
||||
'url': url_path_join(user.url, escapedname, '/'),
|
||||
'pending': None,
|
||||
'ready': True,
|
||||
'stopped': False,
|
||||
'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
|
||||
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
|
||||
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)
|
||||
.one()
|
||||
)
|
||||
oauth_client.allowed_roles = [service_role]
|
||||
oauth_client.allowed_scopes = sorted(roles.roles_to_scopes([service_role]))
|
||||
app.db.commit()
|
||||
|
||||
s = AsyncSession()
|
||||
|
@@ -894,20 +894,18 @@ async def test_server_role_api_calls(
|
||||
assert r.status_code == response
|
||||
|
||||
|
||||
async def test_oauth_allowed_roles(app, create_temp_role):
|
||||
allowed_roles = ['oracle', 'goose']
|
||||
async def test_oauth_client_allowed_scopes(app):
|
||||
allowed_scopes = ['read:users', 'read:groups']
|
||||
service = {
|
||||
'name': 'oas1',
|
||||
'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.init_services()
|
||||
app_service = app.services[0]
|
||||
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):
|
||||
|
@@ -7,7 +7,7 @@ from subprocess import Popen
|
||||
from async_generator import asynccontextmanager
|
||||
|
||||
from .. import orm
|
||||
from ..roles import update_roles
|
||||
from ..roles import roles_to_scopes
|
||||
from ..utils import (
|
||||
exponential_backoff,
|
||||
maybe_future,
|
||||
@@ -97,7 +97,10 @@ async def test_external_service(app):
|
||||
service = app._service_map[name]
|
||||
assert service.oauth_available
|
||||
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]
|
||||
url = public_url(app, service) + '/api/users'
|
||||
r = await async_requests.get(url, allow_redirects=False)
|
||||
|
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
from binascii import hexlify
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -13,6 +13,7 @@ from tornado.httputil import url_concat
|
||||
from tornado.log import app_log
|
||||
|
||||
from .. import orm, roles, scopes
|
||||
from ..roles import roles_to_scopes
|
||||
from ..services.auth import _ExpiringDict
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
@@ -292,9 +293,11 @@ async def test_oauth_service_roles(
|
||||
],
|
||||
},
|
||||
)
|
||||
oauth_client.allowed_roles = [
|
||||
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
|
||||
]
|
||||
oauth_client.allowed_scopes = sorted(
|
||||
roles_to_scopes(
|
||||
[orm.Role.find(app.db, role_name) for role_name in client_allowed_roles]
|
||||
)
|
||||
)
|
||||
app.db.commit()
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
if request_scopes:
|
||||
@@ -486,7 +489,7 @@ async def test_oauth_page_hit(
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
.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.update(scopes.identify_scopes())
|
||||
|
@@ -436,14 +436,16 @@ async def test_hub_connect_url(db):
|
||||
)
|
||||
|
||||
|
||||
async def test_spawner_oauth_roles(app, user):
|
||||
allowed_roles = ["admin", "user"]
|
||||
async def test_spawner_oauth_scopes(app, user):
|
||||
allowed_scopes = ["read:users"]
|
||||
spawner = user.spawners['']
|
||||
spawner.oauth_roles = allowed_roles
|
||||
spawner.oauth_client_allowed_scopes = allowed_scopes
|
||||
# exercise start/stop which assign roles to oauth client
|
||||
await spawner.user.spawn()
|
||||
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()
|
||||
|
||||
|
||||
|
@@ -702,28 +702,12 @@ class User:
|
||||
client_id = spawner.oauth_client_id
|
||||
oauth_provider = self.settings.get('oauth_provider')
|
||||
if oauth_provider:
|
||||
allowed_roles = spawner.oauth_roles
|
||||
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)}")
|
||||
|
||||
allowed_scopes = await spawner._get_oauth_client_allowed_scopes()
|
||||
oauth_client = oauth_provider.add_client(
|
||||
client_id,
|
||||
api_token,
|
||||
url_path_join(self.url, url_escape_path(server_name), 'oauth_callback'),
|
||||
allowed_roles=allowed_roles,
|
||||
allowed_scopes=allowed_scopes,
|
||||
description="Server at %s"
|
||||
% (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
|
@@ -17,7 +17,7 @@ target_version = [
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "2.4.0.dev"
|
||||
current = "3.0.0b1"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
Reference in New Issue
Block a user