From d2bff90f170ea9e26d8d6fee9dde31a7e768b3ad Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 25 Sep 2023 12:18:08 +0200 Subject: [PATCH] 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 --- docs/source/_static/rest-api.yml | 6 +- docs/source/howto/rest.md | 37 ++++++++ docs/source/rbac/scopes.md | 9 +- jupyterhub/tests/browser/test_browser.py | 102 +++++++++++++++++++---- share/jupyterhub/static/js/token.js | 36 +++++--- share/jupyterhub/templates/token.html | 43 ++++++++-- 6 files changed, 195 insertions(+), 38 deletions(-) diff --git a/docs/source/_static/rest-api.yml b/docs/source/_static/rest-api.yml index c3c267fa..aee43414 100644 --- a/docs/source/_static/rest-api.yml +++ b/docs/source/_static/rest-api.yml @@ -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 diff --git a/docs/source/howto/rest.md b/docs/source/howto/rest.md index 503b4ba2..e27b0074 100644 --- a/docs/source/howto/rest.md +++ b/docs/source/howto/rest.md @@ -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} diff --git a/docs/source/rbac/scopes.md b/docs/source/rbac/scopes.md index f30c33f9..c6695e7f 100644 --- a/docs/source/rbac/scopes.md +++ b/docs/source/rbac/scopes.md @@ -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. diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py index d2913465..dc38792a 100644 --- a/jupyterhub/tests/browser/test_browser.py +++ b/jupyterhub/tests/browser/test_browser.py @@ -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", [ diff --git a/share/jupyterhub/static/js/token.js b/share/jupyterhub/static/js/token.js index bda6b360..11cdf260 100644 --- a/share/jupyterhub/static/js/token.js +++ b/share/jupyterhub/static/js/token.js @@ -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; }); diff --git a/share/jupyterhub/templates/token.html b/share/jupyterhub/templates/token.html index 0c209716..3f530734 100644 --- a/share/jupyterhub/templates/token.html +++ b/share/jupyterhub/templates/token.html @@ -20,7 +20,7 @@ This note will help you keep track of what your tokens are for. -

+
{% block expiration_options %} + + 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 JupyterHub documentation for a list of available scopes. + @@ -59,17 +67,18 @@ {% if api_tokens %} -
+

API Tokens

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.

- +
+ @@ -79,7 +88,15 @@ {% for token in api_tokens %} {% block token_row scoped %} - + +
NotePermissions Last used Created Expires
{{token.note}}{{token.note}} +
+ scopes + {% for scope in token.scopes %} +
{{ scope }}
+ {% endfor %} +
+
{%- if token.last_activity -%} {{ token.last_activity.isoformat() + 'Z' }} @@ -113,7 +130,7 @@ {% endif %} {% if oauth_clients %} -
+

Authorized Applications

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.

- +
+ @@ -135,7 +153,18 @@ {% block client_row scoped %} - + +
ApplicationPermissions Last used First authorized
{{ client['description'] }}{{ client['description'] }} +
+ scopes + {# 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 %} +
{{ scope }}
+ {% endfor %} +
+
{%- if client['last_activity'] -%} {{ client['last_activity'].isoformat() + 'Z' }}