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:
Min RK
2023-09-25 12:18:08 +02:00
parent 277d5a3e97
commit d2bff90f17
6 changed files with 195 additions and 38 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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.

View File

@@ -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",
[

View File

@@ -22,19 +22,31 @@ require(["jquery", "jhapi", "moment"], function ($, JHAPI, moment) {
}
var expiration_seconds =
parseInt($("#token-expiration-seconds").val()) || null;
api.request_token(
user,
{
note: note,
expires_in: expiration_seconds,
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();
},
{
success: function (reply) {
$("#token-result").text(reply.token);
$("#token-area").show();
},
},
);
});
return false;
});

View File

@@ -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' }}