diff --git a/datasette/app.py b/datasette/app.py index 9982c58e..daa3848b 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2584,7 +2584,9 @@ class Datasette: r"/-/config(\.(?Pjson))?$", ) add_route( - JsonDataView.as_view(self, "threads.json", self._threads), + JsonDataView.as_view( + self, "threads.json", self._threads, permission="permissions-debug" + ), r"/-/threads(\.(?Pjson))?$", ) add_route( diff --git a/docs/introspection.rst b/docs/introspection.rst index 37f258d7..2d13f68b 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -252,7 +252,7 @@ Without those query string arguments, the page lists up to five tables with dete /-/threads ---------- -Shows details of threads and ``asyncio`` tasks. `Threads example `_: +Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``permissions-debug`` permission, since it exposes runtime internals. `Threads example `_: .. code-block:: json diff --git a/existing-api.md b/existing-api.md index f7f161f3..af7ca3b9 100644 --- a/existing-api.md +++ b/existing-api.md @@ -249,7 +249,7 @@ value replaced by `"***"` (utils/__init__.py:1532-1556). ### GET /-/threads(.json) app.py:2566-2569, `Datasette._threads` (app.py:2268-2285). Permission -`view-instance`. No parameters. +**`permissions-debug`** (exposes runtime internals). No parameters. Response: `num_threads`, `threads` (list of `{name, ident, daemon}`), `num_tasks`, `tasks` (asyncio task repr strings). When the diff --git a/stable-api-recommendations.md b/stable-api-recommendations.md index d3d5b6a6..3c216c04 100644 --- a/stable-api-recommendations.md +++ b/stable-api-recommendations.md @@ -306,9 +306,10 @@ Concerns: unauthorized actors get a uniform response.~~ ✅ **Done** — permission is checked first; the table schema view also now 404s (instead of a 500 KeyError) for an unknown database. -- **(P2) `/-/threads` exposes runtime internals** (thread idents, asyncio +- ~~**(P2) `/-/threads` exposes runtime internals** (thread idents, asyncio task reprs including file paths) behind only `view-instance`. Consider - `permissions-debug`, alongside `/-/actions` which already requires it. + `permissions-debug`, alongside `/-/actions` which already requires it.~~ + ✅ **Done** — `/-/threads` now requires `permissions-debug`. - **(P3) `/-/config` redaction is substring-based** on six key names (app.py:2502-2505); plugins storing secrets under other names leak. Worth a note in plugin authoring docs plus a `redact_keys` plugin hook. diff --git a/tests/test_api.py b/tests/test_api.py index b035edb9..7a575144 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -525,7 +525,11 @@ def test_databases_json(app_client_two_attached_databases_one_immutable): @pytest.mark.asyncio async def test_threads_json(ds_client): - response = await ds_client.get("/-/threads.json") + ds_client.ds.root_enabled = True + try: + response = await ds_client.get("/-/threads.json", actor={"id": "root"}) + finally: + ds_client.ds.root_enabled = False expected_keys = {"ok", "threads", "num_threads"} if sys.version_info >= (3, 7, 0): expected_keys.update({"tasks", "num_tasks"}) diff --git a/tests/test_error_shape.py b/tests/test_error_shape.py index f0ff0a42..a34fbed7 100644 --- a/tests/test_error_shape.py +++ b/tests/test_error_shape.py @@ -554,13 +554,9 @@ async def test_schema_endpoints_no_existence_oracle(tmp_path_factory): assert denied_existing.status_code == denied_missing.status_code == 403 # An authorized actor sees the real thing - root_existing = await ds.client.get( - "/data/-/schema.json", actor={"id": "root"} - ) + root_existing = await ds.client.get("/data/-/schema.json", actor={"id": "root"}) assert root_existing.status_code == 200 - root_missing = await ds.client.get( - "/nope/-/schema.json", actor={"id": "root"} - ) + root_missing = await ds.client.get("/nope/-/schema.json", actor={"id": "root"}) assert root_missing.status_code == 404 finally: ds.close() @@ -604,3 +600,15 @@ async def test_unknown_extra_ignored_on_html_pages(ds_client): response = await ds_client.get("/fixtures/facetable?_extra=nope") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/html") + + +# /-/threads exposes runtime internals and requires permissions-debug + + +@pytest.mark.asyncio +async def test_threads_requires_permissions_debug(ds_error_shape): + denied = await ds_error_shape.client.get("/-/threads.json") + assert_canonical_error(denied, 403) + allowed = await ds_error_shape.client.get("/-/threads.json", actor={"id": "root"}) + assert allowed.status_code == 200 + assert allowed.json()["ok"] is True diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 2af069c9..85598c05 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -184,10 +184,11 @@ async def test_datasette_constructor(): @pytest.mark.asyncio async def test_num_sql_threads_zero(): ds = Datasette([], memory=True, settings={"num_sql_threads": 0}) + ds.root_enabled = True db = ds.add_database(Database(ds, memory_name="test_num_sql_threads_zero")) await db.execute_write("create table t(id integer primary key)") await db.execute_write("insert into t (id) values (1)") - response = await ds.client.get("/-/threads.json") + response = await ds.client.get("/-/threads.json", actor={"id": "root"}) assert response.json() == {"ok": True, "num_threads": 0, "threads": []} response2 = await ds.client.get("/test_num_sql_threads_zero/t.json?_shape=array") assert response2.json() == [{"id": 1}] diff --git a/tests/test_success_envelope.py b/tests/test_success_envelope.py index 22ce6580..97cfd72a 100644 --- a/tests/test_success_envelope.py +++ b/tests/test_success_envelope.py @@ -34,7 +34,6 @@ def ds_envelope(tmp_path_factory): "/-/versions.json", "/-/settings.json", "/-/config.json", - "/-/threads.json", "/-/actor.json", "/-/jump.json", "/-/schema.json", @@ -58,6 +57,7 @@ async def test_success_object_has_ok_true(ds_client, path): ( "/-/rules.json?action=view-instance", "/-/check.json?action=view-instance", + "/-/threads.json", ), ) async def test_permission_debug_success_has_ok_true(ds_envelope, path):