mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
support specifying token permissions in UI
- add scopes field to token form - add permissions column to token tables - expand docs on specifying token scopes, including api example
This commit is contained in:
@@ -552,7 +552,11 @@ paths:
|
|||||||
- oauth2:
|
- oauth2:
|
||||||
- read:tokens
|
- read:tokens
|
||||||
post:
|
post:
|
||||||
summary: Create a new token for the user
|
summary: |
|
||||||
|
Create a new token for the user.
|
||||||
|
Permissions can be limited by specifying a list of `scopes` in the JSON request body
|
||||||
|
(starting in JupyerHub 3.0; previously, permissions could be specified as `roles` could be specified,
|
||||||
|
which is deprecated in 3.0).
|
||||||
parameters:
|
parameters:
|
||||||
- name: name
|
- name: name
|
||||||
in: path
|
in: path
|
||||||
|
@@ -99,9 +99,46 @@ In JupyterHub 2.0,
|
|||||||
specific permissions are now defined as '**scopes**',
|
specific permissions are now defined as '**scopes**',
|
||||||
and can be assigned both at the user/service level,
|
and can be assigned both at the user/service level,
|
||||||
and at the individual token level.
|
and at the individual token level.
|
||||||
|
The previous behavior is represented by the scope `inherit`,
|
||||||
|
and is still the default behavior for requesting a token if limited permissions are not specified.
|
||||||
|
|
||||||
This allows e.g. a user with full admin permissions to request a token with limited permissions.
|
This allows e.g. a user with full admin permissions to request a token with limited permissions.
|
||||||
|
|
||||||
|
In JupyterHub 5.0, you can specify scopes for a token when requesting it via the `/hub/tokens` page as a space-separated list.
|
||||||
|
In JupyterHub 3.0 and later, you can also request tokens with limited scopes via the JupyterHub API (provided you already have a token!):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def request_token(
|
||||||
|
username, *, api_token, scopes=None, expires_in=0, hub_url="http://127.0.0.1:8081"
|
||||||
|
):
|
||||||
|
"""Request a new token for a user"""
|
||||||
|
request_body = {}
|
||||||
|
if expires_in:
|
||||||
|
request_body["expires_in"] = expires_in
|
||||||
|
if scopes:
|
||||||
|
request_body["scopes"] = scopes
|
||||||
|
url = hub_url.rstrip("/") + f"/hub/api/users/{quote(username)}/tokens"
|
||||||
|
r = requests.post(
|
||||||
|
url,
|
||||||
|
data=json.dumps(request_body),
|
||||||
|
headers={"Authorization": f"token {api_token}"},
|
||||||
|
)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
# extract error message for nicer error messages
|
||||||
|
r.reason = r.json().get("message", r.text)
|
||||||
|
r.raise_for_status()
|
||||||
|
# response is a dict and will include the token itself in the 'token' field,
|
||||||
|
# as well as other fields about the token
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
request_token("myusername", scopes=["list:users"], api_token="abc123")
|
||||||
|
```
|
||||||
|
|
||||||
## Updating to admin services
|
## Updating to admin services
|
||||||
|
|
||||||
```{note}
|
```{note}
|
||||||
|
@@ -300,6 +300,11 @@ Custom scope _filters_ are NOT supported.
|
|||||||
|
|
||||||
### Scopes and APIs
|
### Scopes and APIs
|
||||||
|
|
||||||
The scopes are also listed in the [](jupyterhub-rest-API) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
The scopes are also listed in the [](jupyterhub-rest-API) documentation.
|
||||||
|
Each API endpoint has a list of scopes which can be used to access the API;
|
||||||
|
if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
||||||
|
|
||||||
Listed scopes by each API endpoint reflect the "lowest" permissions required to gain any access to the corresponding API. For example, posting user's activity (_POST /users/:name/activity_) needs `users:activity` scope. If scope `users` is passed during the request, the access will be granted as the required scope is a subscope of the `users` scope. If, on the other hand, `read:users:activity` scope is passed, the access will be denied.
|
Listed scopes by each API endpoint reflect the "lowest" permissions required to gain any access to the corresponding API.
|
||||||
|
For example, posting user's activity (_POST /users/:name/activity_) needs `users:activity` scope.
|
||||||
|
If scope `users` is held by the request, the access will be granted as the required scope is a subscope of the `users` scope.
|
||||||
|
If, on the other hand, `read:users:activity` scope is the only scope held, the request will be denied.
|
||||||
|
@@ -418,6 +418,12 @@ async def test_token_request_form_and_panel(app, browser, user):
|
|||||||
selected_value = dropdown.locator('option[selected]')
|
selected_value = dropdown.locator('option[selected]')
|
||||||
await expect(selected_value).to_have_text("Never")
|
await expect(selected_value).to_have_text("Never")
|
||||||
|
|
||||||
|
# check scopes field
|
||||||
|
scopes_input = browser.get_by_label("Permissions")
|
||||||
|
await expect(scopes_input).to_be_editable()
|
||||||
|
await expect(scopes_input).to_be_enabled()
|
||||||
|
await expect(scopes_input).to_be_empty()
|
||||||
|
|
||||||
# verify that "Your new API Token" panel shows up with the new API token
|
# verify that "Your new API Token" panel shows up with the new API token
|
||||||
await request_btn.click()
|
await request_btn.click()
|
||||||
await browser.wait_for_load_state("load")
|
await browser.wait_for_load_state("load")
|
||||||
@@ -472,10 +478,8 @@ async def test_request_token_expiration(app, browser, token_opt, note, user):
|
|||||||
note_field = browser.get_by_role("textbox").first
|
note_field = browser.get_by_role("textbox").first
|
||||||
await note_field.fill(note)
|
await note_field.fill(note)
|
||||||
# click on Request token button
|
# click on Request token button
|
||||||
reqeust_btn = browser.locator('//div[@class="text-center"]').get_by_role(
|
request_button = browser.locator('//button[@type="submit"]')
|
||||||
"button"
|
await request_button.click()
|
||||||
)
|
|
||||||
await reqeust_btn.click()
|
|
||||||
# wait for token response to show up on the page
|
# wait for token response to show up on the page
|
||||||
await browser.wait_for_load_state("load")
|
await browser.wait_for_load_state("load")
|
||||||
token_result = browser.locator("#token-result")
|
token_result = browser.locator("#token-result")
|
||||||
@@ -483,7 +487,7 @@ async def test_request_token_expiration(app, browser, token_opt, note, user):
|
|||||||
# reload the page
|
# reload the page
|
||||||
await browser.reload(wait_until="load")
|
await browser.reload(wait_until="load")
|
||||||
# API Tokens table: verify that elements are displayed
|
# API Tokens table: verify that elements are displayed
|
||||||
api_token_table_area = browser.locator('//div[@class="row"]').nth(2)
|
api_token_table_area = browser.locator("div#api-tokens-section").nth(0)
|
||||||
await expect(api_token_table_area.get_by_role("table")).to_be_visible()
|
await expect(api_token_table_area.get_by_role("table")).to_be_visible()
|
||||||
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
|
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
|
||||||
|
|
||||||
@@ -498,27 +502,31 @@ async def test_request_token_expiration(app, browser, token_opt, note, user):
|
|||||||
else:
|
else:
|
||||||
expected_note = "Requested via token page"
|
expected_note = "Requested via token page"
|
||||||
assert orm_token.note == expected_note
|
assert orm_token.note == expected_note
|
||||||
|
|
||||||
note_on_page = (
|
note_on_page = (
|
||||||
await api_token_table_area.locator("tr.token-row")
|
await api_token_table_area.locator("tr.token-row")
|
||||||
.get_by_role("cell")
|
.get_by_role("cell")
|
||||||
.nth(0)
|
.nth(0)
|
||||||
.inner_text()
|
.inner_text()
|
||||||
)
|
)
|
||||||
|
|
||||||
assert note_on_page == expected_note
|
assert note_on_page == expected_note
|
||||||
|
|
||||||
last_used_text = (
|
last_used_text = (
|
||||||
await api_token_table_area.locator("tr.token-row")
|
await api_token_table_area.locator("tr.token-row")
|
||||||
.get_by_role("cell")
|
.get_by_role("cell")
|
||||||
.nth(1)
|
.nth(2)
|
||||||
.inner_text()
|
|
||||||
)
|
|
||||||
expires_at_text = (
|
|
||||||
await api_token_table_area.locator("tr.token-row")
|
|
||||||
.get_by_role("cell")
|
|
||||||
.nth(3)
|
|
||||||
.inner_text()
|
.inner_text()
|
||||||
)
|
)
|
||||||
assert last_used_text == "Never"
|
assert last_used_text == "Never"
|
||||||
|
|
||||||
|
expires_at_text = (
|
||||||
|
await api_token_table_area.locator("tr.token-row")
|
||||||
|
.get_by_role("cell")
|
||||||
|
.nth(4)
|
||||||
|
.inner_text()
|
||||||
|
)
|
||||||
|
|
||||||
if token_opt == "Never":
|
if token_opt == "Never":
|
||||||
assert orm_token.expires_at is None
|
assert orm_token.expires_at is None
|
||||||
assert expires_at_text == "Never"
|
assert expires_at_text == "Never"
|
||||||
@@ -533,15 +541,77 @@ async def test_request_token_expiration(app, browser, token_opt, note, user):
|
|||||||
assert expires_at_text == "Never"
|
assert expires_at_text == "Never"
|
||||||
# verify that the button for revoke is presented
|
# verify that the button for revoke is presented
|
||||||
revoke_btn = (
|
revoke_btn = (
|
||||||
api_token_table_area.locator("tr.token-row")
|
api_token_table_area.locator("tr.token-row").get_by_role("button").nth(0)
|
||||||
.get_by_role("cell")
|
|
||||||
.nth(4)
|
|
||||||
.get_by_role("button")
|
|
||||||
)
|
)
|
||||||
await expect(revoke_btn).to_be_visible()
|
await expect(revoke_btn).to_be_visible()
|
||||||
await expect(revoke_btn).to_have_text("revoke")
|
await expect(revoke_btn).to_have_text("revoke")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"permissions_str, granted",
|
||||||
|
[
|
||||||
|
("", {"inherit"}),
|
||||||
|
("inherit", {"inherit"}),
|
||||||
|
("read:users!user, ", {"read:users!user"}),
|
||||||
|
(
|
||||||
|
"read:users!user, access:servers!user",
|
||||||
|
{"read:users!user", "access:servers!user"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"read:users:name!user access:servers!user ,, read:servers!user",
|
||||||
|
{"read:users:name!user", "access:servers!user", "read:servers!user"},
|
||||||
|
),
|
||||||
|
# errors
|
||||||
|
("nosuchscope", "does not exist"),
|
||||||
|
("inherit, nosuchscope", "does not exist"),
|
||||||
|
("admin:users", "Not assigning requested scopes"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_request_token_permissions(app, browser, permissions_str, granted, user):
|
||||||
|
"""verify request token with the different options"""
|
||||||
|
|
||||||
|
# open the token page
|
||||||
|
await open_token_page(app, browser, user)
|
||||||
|
scopes_input = browser.get_by_label("Permissions")
|
||||||
|
await scopes_input.fill(permissions_str)
|
||||||
|
request_button = browser.locator('//button[@type="submit"]')
|
||||||
|
await request_button.click()
|
||||||
|
|
||||||
|
if isinstance(granted, str):
|
||||||
|
expected_error = granted
|
||||||
|
granted = False
|
||||||
|
|
||||||
|
if not granted:
|
||||||
|
error_dialog = browser.locator("#error-dialog")
|
||||||
|
await expect(error_dialog).to_be_visible()
|
||||||
|
error_message = await error_dialog.locator(".modal-body").inner_text()
|
||||||
|
assert "API request failed (400)" in error_message
|
||||||
|
assert expected_error in error_message
|
||||||
|
return
|
||||||
|
|
||||||
|
await browser.reload(wait_until="load")
|
||||||
|
|
||||||
|
# API Tokens table: verify that elements are displayed
|
||||||
|
api_token_table_area = browser.locator("div#api-tokens-section").nth(0)
|
||||||
|
await expect(api_token_table_area.get_by_role("table")).to_be_visible()
|
||||||
|
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
|
||||||
|
|
||||||
|
# getting values from DB to compare with values on UI
|
||||||
|
assert len(user.api_tokens) == 1
|
||||||
|
orm_token = user.api_tokens[-1]
|
||||||
|
assert set(orm_token.scopes) == granted
|
||||||
|
|
||||||
|
permissions_on_page = (
|
||||||
|
await api_token_table_area.locator("tr.token-row")
|
||||||
|
.get_by_role("cell")
|
||||||
|
.nth(1)
|
||||||
|
.locator('//pre[@class="token-scope"]')
|
||||||
|
.all_text_contents()
|
||||||
|
)
|
||||||
|
# specifically use list to test that entries don't appear twice
|
||||||
|
assert sorted(permissions_on_page) == sorted(granted)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"token_type",
|
"token_type",
|
||||||
[
|
[
|
||||||
|
@@ -22,19 +22,31 @@ require(["jquery", "jhapi", "moment"], function ($, JHAPI, moment) {
|
|||||||
}
|
}
|
||||||
var expiration_seconds =
|
var expiration_seconds =
|
||||||
parseInt($("#token-expiration-seconds").val()) || null;
|
parseInt($("#token-expiration-seconds").val()) || null;
|
||||||
api.request_token(
|
|
||||||
user,
|
var scope_text = $("#token-scopes").val() || "";
|
||||||
{
|
|
||||||
note: note,
|
// split on commas and/or space
|
||||||
expires_in: expiration_seconds,
|
var scope_list = scope_text.split(/[\s,]+/).filter(function (scope) {
|
||||||
|
// filter out empty scopes
|
||||||
|
return scope.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
var request_body = {
|
||||||
|
note: note,
|
||||||
|
expires_in: expiration_seconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scope_list.length > 0) {
|
||||||
|
// add scopes to body, if defined
|
||||||
|
request_body.scopes = scope_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.request_token(user, request_body, {
|
||||||
|
success: function (reply) {
|
||||||
|
$("#token-result").text(reply.token);
|
||||||
|
$("#token-area").show();
|
||||||
},
|
},
|
||||||
{
|
});
|
||||||
success: function (reply) {
|
|
||||||
$("#token-result").text(reply.token);
|
|
||||||
$("#token-area").show();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -20,7 +20,7 @@
|
|||||||
<small id="note-note" class="form-text text-muted">
|
<small id="note-note" class="form-text text-muted">
|
||||||
This note will help you keep track of what your tokens are for.
|
This note will help you keep track of what your tokens are for.
|
||||||
</small>
|
</small>
|
||||||
<br><br>
|
<br/>
|
||||||
<label for="token-expiration-seconds">Token expires in</label>
|
<label for="token-expiration-seconds">Token expires in</label>
|
||||||
{% block expiration_options %}
|
{% block expiration_options %}
|
||||||
<select id="token-expiration-seconds"
|
<select id="token-expiration-seconds"
|
||||||
@@ -35,6 +35,14 @@
|
|||||||
<small id="note-expires-at" class="form-text text-muted">
|
<small id="note-expires-at" class="form-text text-muted">
|
||||||
You can configure when your token will expire.
|
You can configure when your token will expire.
|
||||||
</small>
|
</small>
|
||||||
|
<br/>
|
||||||
|
<label for="token-scopes">Permissions</label>
|
||||||
|
<input id="token-scopes" class="form-control" placeholder="list of scopes for the token to have, separated by space">
|
||||||
|
<small id="note-token-scopes" class="form-text text-muted">
|
||||||
|
You can limit the permissions of the token so it can only do what you want it to.
|
||||||
|
If none are specified, the token will have permission to do everything you can do.
|
||||||
|
See the <a href="https://jupyterhub.readthedocs.io/en/stable/rbac/scopes.html#available-scopes">JupyterHub documentation for a list of available scopes</a>.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,17 +67,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if api_tokens %}
|
{% if api_tokens %}
|
||||||
<div class="row">
|
<div class="row" id="api-tokens-section">
|
||||||
<h2>API Tokens</h2>
|
<h2>API Tokens</h2>
|
||||||
<p>
|
<p>
|
||||||
These are tokens with access to the JupyterHub API.
|
These are tokens with access to the JupyterHub API.
|
||||||
Permissions for each token may be viewed via the JupyterHub tokens API.
|
Permissions for each token may be viewed via the JupyterHub tokens API.
|
||||||
Revoking the API token for a running server will require restarting that server.
|
Revoking the API token for a running server will require restarting that server.
|
||||||
</p>
|
</p>
|
||||||
<table class="table table-striped">
|
<table class="table table-striped" id="api-tokens-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Note</th>
|
<th>Note</th>
|
||||||
|
<th>Permissions</th>
|
||||||
<th>Last used</th>
|
<th>Last used</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Expires</th>
|
<th>Expires</th>
|
||||||
@@ -79,7 +88,15 @@
|
|||||||
{% for token in api_tokens %}
|
{% for token in api_tokens %}
|
||||||
<tr class="token-row" data-token-id="{{token.api_id}}">
|
<tr class="token-row" data-token-id="{{token.api_id}}">
|
||||||
{% block token_row scoped %}
|
{% block token_row scoped %}
|
||||||
<td class="note-col col-sm-5">{{token.note}}</td>
|
<td class="note-col col-sm-4">{{token.note}}</td>
|
||||||
|
<td class="scope-col col-sm-1">
|
||||||
|
<details>
|
||||||
|
<summary>scopes</summary>
|
||||||
|
{% for scope in token.scopes %}
|
||||||
|
<pre class="token-scope">{{ scope }}</pre>
|
||||||
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
<td class="time-col col-sm-3">
|
<td class="time-col col-sm-3">
|
||||||
{%- if token.last_activity -%}
|
{%- if token.last_activity -%}
|
||||||
{{ token.last_activity.isoformat() + 'Z' }}
|
{{ token.last_activity.isoformat() + 'Z' }}
|
||||||
@@ -113,7 +130,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if oauth_clients %}
|
{% if oauth_clients %}
|
||||||
<div class="row">
|
<div class="row" id="oauth-clients-section">
|
||||||
<h2>Authorized Applications</h2>
|
<h2>Authorized Applications</h2>
|
||||||
<p>
|
<p>
|
||||||
These are applications that use OAuth with JupyterHub
|
These are applications that use OAuth with JupyterHub
|
||||||
@@ -122,10 +139,11 @@
|
|||||||
OAuth tokens can generally only be used to identify you,
|
OAuth tokens can generally only be used to identify you,
|
||||||
not take actions on your behalf.
|
not take actions on your behalf.
|
||||||
</p>
|
</p>
|
||||||
<table class="table table-striped">
|
<table class="table table-striped" id="oauth-tokens-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Application</th>
|
<th>Application</th>
|
||||||
|
<th>Permissions</th>
|
||||||
<th>Last used</th>
|
<th>Last used</th>
|
||||||
<th>First authorized</th>
|
<th>First authorized</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -135,7 +153,18 @@
|
|||||||
<tr class="token-row"
|
<tr class="token-row"
|
||||||
data-token-id="{{ client['token_id'] }}">
|
data-token-id="{{ client['token_id'] }}">
|
||||||
{% block client_row scoped %}
|
{% block client_row scoped %}
|
||||||
<td class="note-col col-sm-5">{{ client['description'] }}</td>
|
<td class="note-col col-sm-4">{{ client['description'] }}</td>
|
||||||
|
<td class="scope-col col-sm-1">
|
||||||
|
<details>
|
||||||
|
<summary>scopes</summary>
|
||||||
|
{# create set of scopes on all tokens -#}
|
||||||
|
{# sum concatenates all token.scopes into a single list -#}
|
||||||
|
{# then filter to unique set and sort -#}
|
||||||
|
{% for scope in client.tokens | sum(attribute="scopes", start=[]) | unique | sort %}
|
||||||
|
<pre class="token-scope">{{ scope }}</pre>
|
||||||
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
<td class="time-col col-sm-3">
|
<td class="time-col col-sm-3">
|
||||||
{%- if client['last_activity'] -%}
|
{%- if client['last_activity'] -%}
|
||||||
{{ client['last_activity'].isoformat() + 'Z' }}
|
{{ client['last_activity'].isoformat() + 'Z' }}
|
||||||
|
Reference in New Issue
Block a user