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:
|
||||
- read:tokens
|
||||
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:
|
||||
- name: name
|
||||
in: path
|
||||
|
@@ -99,9 +99,46 @@ In JupyterHub 2.0,
|
||||
specific permissions are now defined as '**scopes**',
|
||||
and can be assigned both at the user/service 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.
|
||||
|
||||
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
|
||||
|
||||
```{note}
|
||||
|
@@ -300,6 +300,11 @@ Custom scope _filters_ are NOT supported.
|
||||
|
||||
### 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]')
|
||||
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
|
||||
await request_btn.click()
|
||||
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
|
||||
await note_field.fill(note)
|
||||
# click on Request token button
|
||||
reqeust_btn = browser.locator('//div[@class="text-center"]').get_by_role(
|
||||
"button"
|
||||
)
|
||||
await reqeust_btn.click()
|
||||
request_button = browser.locator('//button[@type="submit"]')
|
||||
await request_button.click()
|
||||
# wait for token response to show up on the page
|
||||
await browser.wait_for_load_state("load")
|
||||
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
|
||||
await browser.reload(wait_until="load")
|
||||
# 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.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:
|
||||
expected_note = "Requested via token page"
|
||||
assert orm_token.note == expected_note
|
||||
|
||||
note_on_page = (
|
||||
await api_token_table_area.locator("tr.token-row")
|
||||
.get_by_role("cell")
|
||||
.nth(0)
|
||||
.inner_text()
|
||||
)
|
||||
|
||||
assert note_on_page == expected_note
|
||||
|
||||
last_used_text = (
|
||||
await api_token_table_area.locator("tr.token-row")
|
||||
.get_by_role("cell")
|
||||
.nth(1)
|
||||
.inner_text()
|
||||
)
|
||||
expires_at_text = (
|
||||
await api_token_table_area.locator("tr.token-row")
|
||||
.get_by_role("cell")
|
||||
.nth(3)
|
||||
.nth(2)
|
||||
.inner_text()
|
||||
)
|
||||
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":
|
||||
assert orm_token.expires_at is None
|
||||
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"
|
||||
# verify that the button for revoke is presented
|
||||
revoke_btn = (
|
||||
api_token_table_area.locator("tr.token-row")
|
||||
.get_by_role("cell")
|
||||
.nth(4)
|
||||
.get_by_role("button")
|
||||
api_token_table_area.locator("tr.token-row").get_by_role("button").nth(0)
|
||||
)
|
||||
await expect(revoke_btn).to_be_visible()
|
||||
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(
|
||||
"token_type",
|
||||
[
|
||||
|
@@ -22,19 +22,31 @@ require(["jquery", "jhapi", "moment"], function ($, JHAPI, moment) {
|
||||
}
|
||||
var expiration_seconds =
|
||||
parseInt($("#token-expiration-seconds").val()) || null;
|
||||
api.request_token(
|
||||
user,
|
||||
{
|
||||
|
||||
var scope_text = $("#token-scopes").val() || "";
|
||||
|
||||
// split on commas and/or space
|
||||
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();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
|
@@ -20,7 +20,7 @@
|
||||
<small id="note-note" class="form-text text-muted">
|
||||
This note will help you keep track of what your tokens are for.
|
||||
</small>
|
||||
<br><br>
|
||||
<br/>
|
||||
<label for="token-expiration-seconds">Token expires in</label>
|
||||
{% block expiration_options %}
|
||||
<select id="token-expiration-seconds"
|
||||
@@ -35,6 +35,14 @@
|
||||
<small id="note-expires-at" class="form-text text-muted">
|
||||
You can configure when your token will expire.
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -59,17 +67,18 @@
|
||||
</div>
|
||||
|
||||
{% if api_tokens %}
|
||||
<div class="row">
|
||||
<div class="row" id="api-tokens-section">
|
||||
<h2>API Tokens</h2>
|
||||
<p>
|
||||
These are tokens with access to the JupyterHub 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.
|
||||
</p>
|
||||
<table class="table table-striped">
|
||||
<table class="table table-striped" id="api-tokens-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Note</th>
|
||||
<th>Permissions</th>
|
||||
<th>Last used</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
@@ -79,7 +88,15 @@
|
||||
{% for token in api_tokens %}
|
||||
<tr class="token-row" data-token-id="{{token.api_id}}">
|
||||
{% 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">
|
||||
{%- if token.last_activity -%}
|
||||
{{ token.last_activity.isoformat() + 'Z' }}
|
||||
@@ -113,7 +130,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if oauth_clients %}
|
||||
<div class="row">
|
||||
<div class="row" id="oauth-clients-section">
|
||||
<h2>Authorized Applications</h2>
|
||||
<p>
|
||||
These are applications that use OAuth with JupyterHub
|
||||
@@ -122,10 +139,11 @@
|
||||
OAuth tokens can generally only be used to identify you,
|
||||
not take actions on your behalf.
|
||||
</p>
|
||||
<table class="table table-striped">
|
||||
<table class="table table-striped" id="oauth-tokens-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application</th>
|
||||
<th>Permissions</th>
|
||||
<th>Last used</th>
|
||||
<th>First authorized</th>
|
||||
</tr>
|
||||
@@ -135,7 +153,18 @@
|
||||
<tr class="token-row"
|
||||
data-token-id="{{ client['token_id'] }}">
|
||||
{% 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">
|
||||
{%- if client['last_activity'] -%}
|
||||
{{ client['last_activity'].isoformat() + 'Z' }}
|
||||
|
Reference in New Issue
Block a user