Repository: jupyterhub/jupyterhub

Issue:
How to create token with limited permissions?
The [Assigning permissions to a token](https://jupyterhub.readthedocs.io/en/stable/howto/rest.html#assigning-permissions-to-a-token) section of the documentation says:

> 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.
> 
> This allows e.g. a user with full admin permissions to request a token with limited permissions.

But the documentation on _how_ to actually obtain a token with limited permissions/scopes is missing. The web UI allows to set only the expiration date and it also does not show which tokens have which permissions/scopes.


Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

There should definitely be a clear, explicit tutorial on this!

The only documentation so far is in the [rest api reference](https://jupyterhub.readthedocs.io/en/stable/reference/rest-api.html#/default/post_users__name__tokens), which is not particularly well rendered, as the description of the scopes field is behind a few clicks on the request body schema:

<img width="741" alt="Screenshot 2023-09-25 at 10 12 34" src="https://github.com/jupyterhub/jupyterhub/assets/151929/19a758db-2db6-4914-ad27-0d982a067b15">

While I work on these docs, the way to request a token with scopes is:

```python
import json
import requests

api_token = "..."
hub_url = "https://hub.example.com"

# pick your scopes:
scopes = ["read:users", "admin:servers!user"]
# your name
username = "myself"

r = requests.post(hub_url + f"/hub/api/users/{username}/tokens", body=json.dumps({"scopes": scopes}), headers={"Authorization: f"Bearer {api_token}"})
r.raise_for_status()
token_response = r.json()
```

There is not UI for this, mainly because generating good inputs for the list of scopes and filters is some tricky UI work. We could have a free text field in the meantime. Not the friendliest, but at least fully functional.

Resolving patch:
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -439,6 +439,18 @@ async def post(self, user_name):
         token_roles = body.get("roles")
         token_scopes = body.get("scopes")
 
+        # check type of permissions
+        for key in ("roles", "scopes"):
+            value = body.get(key)
+            if value is None:
+                continue
+            if not isinstance(value, list) or not all(
+                isinstance(item, str) for item in value
+            ):
+                raise web.HTTPError(
+                    400, f"token {key} must be null or a list of strings, not {value!r}"
+                )
+
         try:
             api_token = user.new_api_token(
                 note=note,
@@ -446,7 +458,7 @@ async def post(self, user_name):
                 roles=token_roles,
                 scopes=token_scopes,
             )
-        except ValueError as e:
+        except (ValueError, KeyError) as e:
             raise web.HTTPError(400, str(e))
         if requester is not user:
             self.log.info(


Test patch:
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
--- 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,26 +502,30 @@ 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)
+        .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(3)
+        .nth(4)
         .inner_text()
     )
-    assert last_used_text == "Never"
 
     if token_opt == "Never":
         assert orm_token.expires_at is None
@@ -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",
     [