From 221be2632e6516e70796c234a1f649018412cdf7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 24 May 2026 22:41:56 -0700 Subject: [PATCH] Add query management actions and write analysis Refs #2735 --- datasette/app.py | 30 +++++++++++++++ datasette/default_actions.py | 26 +++++++++++++ tests/test_queries.py | 73 ++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index d64337d1..9b7fa61e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -93,6 +93,7 @@ from .utils import ( module_from_path, move_plugins_and_allow, move_table_config, + named_parameters, parse_metadata, resolve_env_secrets, resolve_routes, @@ -1237,6 +1238,35 @@ class Datasette: ) return {row["name"]: self._query_row_to_dict(row) for row in rows} + async def ensure_query_write_permissions(self, database, sql, *, actor=None): + write_actions = { + "insert": "insert-row", + "update": "update-row", + "delete": "delete-row", + } + db = self.get_database(database) + params = {name: "" for name in named_parameters(sql)} + try: + analysis = await db.analyze_sql(sql, params) + except sqlite3.DatabaseError as ex: + raise Forbidden(f"Could not analyze query: {ex}") from ex + + for access in analysis.table_accesses: + action = write_actions.get(access.operation) + if action is None: + continue + if access.database != database: + raise Forbidden("Writable queries may not write to attached databases") + if not await self.allowed( + action=action, + resource=TableResource(database=access.database, table=access.table), + actor=actor, + ): + raise Forbidden( + f"Permission denied: need {action} on {access.database}/{access.table}" + ) + return analysis + # Column types API async def _get_resource_column_details(self, database: str, resource: str): diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 149a4e5f..e0e0aee5 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -54,6 +54,20 @@ def register_actions(): description="Create tables", resource_class=DatabaseResource, ), + Action( + name="insert-query", + abbr="iq", + description="Create saved queries", + resource_class=DatabaseResource, + also_requires="execute-sql", + ), + Action( + name="publish-query", + abbr="pq", + description="Publish saved queries for actors without execute-sql", + resource_class=DatabaseResource, + also_requires="insert-query", + ), # Table-level actions (child-level) Action( name="view-table", @@ -104,4 +118,16 @@ def register_actions(): description="View named query results", resource_class=QueryResource, ), + Action( + name="update-query", + abbr="uq", + description="Update saved queries", + resource_class=QueryResource, + ), + Action( + name="delete-query", + abbr="dq", + description="Delete saved queries", + resource_class=QueryResource, + ), ) diff --git a/tests/test_queries.py b/tests/test_queries.py index dcebc2cd..01174a18 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -2,6 +2,7 @@ import pytest from datasette.app import Datasette from datasette.resources import DatabaseResource, QueryResource +from datasette.utils.asgi import Forbidden @pytest.mark.asyncio @@ -206,3 +207,75 @@ async def test_unpublished_query_requires_execute_sql_but_published_does_not(): resource=QueryResource("data", "published"), actor=None, ) + + +@pytest.mark.asyncio +async def test_query_actions_are_registered(): + ds = Datasette() + await ds.invoke_startup() + + assert ds.get_action("insert-query").resource_class is DatabaseResource + assert ds.get_action("publish-query").resource_class is DatabaseResource + assert ds.get_action("update-query").resource_class is QueryResource + assert ds.get_action("delete-query").resource_class is QueryResource + + +@pytest.mark.asyncio +async def test_analyze_write_query_requires_table_permissions(): + ds = Datasette(memory=True, default_deny=True) + db = ds.add_memory_database("query_write_permissions", name="data") + await db.execute_write("create table dogs (id integer primary key, name text)") + await ds.invoke_startup() + + actor = {"id": "writer"} + await ds.add_query( + "data", + "write_dog", + "insert into dogs (name) values (:name)", + is_write=True, + source="user", + owner_id="writer", + ) + + with pytest.raises(Forbidden): + await ds.ensure_query_write_permissions( + "data", + "insert into dogs (name) values (:name)", + actor=actor, + ) + + ds.config = { + "databases": { + "data": { + "tables": { + "dogs": { + "permissions": { + "insert-row": {"id": "writer"}, + } + } + } + } + } + } + + await ds.ensure_query_write_permissions( + "data", + "insert into dogs (name) values (:name)", + actor=actor, + ) + + +@pytest.mark.asyncio +async def test_analyze_write_query_rejects_writes_to_attached_databases(): + ds = Datasette(memory=True, default_deny=True) + db = ds.add_memory_database("query_attached_writes", name="data") + await db.execute_write("attach database ':memory:' as extra") + await db.execute_write("create table extra.cats (id integer primary key)") + await ds.invoke_startup() + + with pytest.raises(Forbidden): + await ds.ensure_query_write_permissions( + "data", + "insert into extra.cats (id) values (1)", + actor={"id": "writer"}, + )