From b7505a9fc22fd96f0c6aad60c8b149bc1978d7b0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 25 May 2026 08:49:18 -0700 Subject: [PATCH] Add execute write SQL database action Refs #2735 --- datasette/default_database_actions.py | 22 +++++++++++++++++ datasette/plugins.py | 1 + tests/test_queries.py | 34 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 datasette/default_database_actions.py diff --git a/datasette/default_database_actions.py b/datasette/default_database_actions.py new file mode 100644 index 00000000..78055392 --- /dev/null +++ b/datasette/default_database_actions.py @@ -0,0 +1,22 @@ +from datasette import hookimpl +from datasette.resources import DatabaseResource + + +@hookimpl +def database_actions(datasette, actor, database, request): + async def inner(): + if not await datasette.allowed( + action="execute-write-sql", + resource=DatabaseResource(database), + actor=actor, + ): + return [] + return [ + { + "href": datasette.urls.database(database) + "/-/execute-write", + "label": "Execute write SQL", + "description": "Run writable SQL with table permission checks.", + } + ] + + return inner diff --git a/datasette/plugins.py b/datasette/plugins.py index f532ac60..5a31cdad 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -30,6 +30,7 @@ DEFAULT_PLUGINS = ( "datasette.blob_renderer", "datasette.default_debug_menu", "datasette.default_jump_items", + "datasette.default_database_actions", "datasette.handle_exception", "datasette.forbidden", "datasette.events", diff --git a/tests/test_queries.py b/tests/test_queries.py index 05bc5ee1..1c9175cc 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -515,6 +515,40 @@ async def test_execute_write_get_prepopulates_without_executing(): assert (await db.execute("select count(*) from dogs")).first()[0] == 0 +@pytest.mark.asyncio +async def test_database_action_menu_links_to_execute_write_for_permitted_actor(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-database": { + "id": ["writer", "viewer"], + }, + "execute-write-sql": {"id": "writer"}, + } + } + } + }, + ) + ds.add_memory_database("execute_write_menu", name="data") + await ds.invoke_startup() + + anonymous_response = await ds.client.get("/data") + viewer_response = await ds.client.get("/data", actor={"id": "viewer"}) + writer_response = await ds.client.get("/data", actor={"id": "writer"}) + + assert anonymous_response.status_code == 403 + assert viewer_response.status_code == 200 + assert "Execute write SQL" not in viewer_response.text + assert writer_response.status_code == 200 + assert "Database actions" in writer_response.text + assert 'href="/data/-/execute-write"' in writer_response.text + assert "Execute write SQL" in writer_response.text + + @pytest.mark.asyncio async def test_execute_write_post_requires_database_and_table_permissions(): ds = Datasette(