From b4c63966f81599b635a702fd7d971837d01c8bca Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 24 May 2026 22:40:22 -0700 Subject: [PATCH] Load saved queries into permission resources Refs #2735 --- datasette/app.py | 51 +++++++++----- datasette/default_permissions/__init__.py | 1 + datasette/default_permissions/defaults.py | 56 ++++++++++++++- datasette/resources.py | 46 ++---------- tests/test_queries.py | 85 +++++++++++++++++++++++ 5 files changed, 179 insertions(+), 60 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 518215fd..d64337d1 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -572,6 +572,35 @@ class Datasette: # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log # a warning to user that they should delete their metadata.json file + async def apply_queries_config(self): + # Apply configured query entries from datasette.yaml to the internal table. + await self.get_internal_database().execute_write( + "DELETE FROM queries WHERE source = 'config'" + ) + for dbname, db_config in ((self.config or {}).get("databases") or {}).items(): + for query_name, query_config in (db_config.get("queries") or {}).items(): + if not isinstance(query_config, dict): + query_config = {"sql": query_config} + await self.add_query( + dbname, + query_name, + query_config["sql"], + title=query_config.get("title"), + description=query_config.get("description"), + description_html=query_config.get("description_html"), + hide_sql=bool(query_config.get("hide_sql")), + fragment=query_config.get("fragment"), + parameters=query_config.get("params"), + is_write=bool(query_config.get("write")), + published=bool(query_config.get("published")), + source="config", + on_success_message=query_config.get("on_success_message"), + on_success_message_sql=query_config.get("on_success_message_sql"), + on_success_redirect=query_config.get("on_success_redirect"), + on_error_message=query_config.get("on_error_message"), + on_error_redirect=query_config.get("on_error_redirect"), + ) + def get_jinja_environment(self, request: Request = None) -> Environment: environment = self._jinja_env if request: @@ -732,6 +761,7 @@ class Datasette: await await_me_maybe(hook) # Ensure internal tables and metadata are populated before startup hooks await self._refresh_schemas() + await self.apply_queries_config() # Load column_types from config into internal DB await self._apply_column_types_config() for hook in pm.hook.startup(datasette=self): @@ -1439,27 +1469,10 @@ class Datasette: return self.static_hash("app.css") async def get_canned_queries(self, database_name, actor): - queries = {} - for more_queries in pm.hook.canned_queries( - datasette=self, - database=database_name, - actor=actor, - ): - more_queries = await await_me_maybe(more_queries) - queries.update(more_queries or {}) - # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}} - for key in queries: - if not isinstance(queries[key], dict): - queries[key] = {"sql": queries[key]} - # Also make sure "name" is available: - queries[key]["name"] = key - return queries + return await self.get_queries(database_name) async def get_canned_query(self, database_name, query_name, actor): - queries = await self.get_canned_queries(database_name, actor) - query = queries.get(query_name) - if query: - return query + return await self.get_query(database_name, query_name) def _prepare_connection(self, conn, database): conn.row_factory = sqlite3.Row diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index 9e3bb648..5a53dbe7 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -35,6 +35,7 @@ from .config import config_permissions_sql as config_permissions_sql from .defaults import ( default_allow_sql_check as default_allow_sql_check, default_action_permissions_sql as default_action_permissions_sql, + default_query_permissions_sql as default_query_permissions_sql, DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, ) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 4c74219d..2613c4f4 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -21,7 +21,6 @@ DEFAULT_ALLOW_ACTIONS = frozenset( "view-database", "view-database-download", "view-table", - "view-query", "execute-sql", } ) @@ -67,3 +66,58 @@ async def default_action_permissions_sql( return PermissionSQL.allow(reason=reason) return None + + +@hookimpl(specname="permission_resources_sql") +async def default_query_permissions_sql( + datasette: "Datasette", + actor: Optional[dict], + action: str, +) -> Optional[PermissionSQL]: + if action != "view-query": + return None + + execute_sql = await datasette.allowed_resources_sql( + action="execute-sql", actor=actor + ) + sql = execute_sql.sql + params = {} + for key, value in execute_sql.params.items(): + new_key = f"query_execute_sql_{key}" + sql = sql.replace(f":{key}", f":{new_key}") + params[new_key] = value + + trusted_writable_sql = "" + if not datasette.default_deny: + trusted_writable_sql = """ + UNION ALL + SELECT database_name AS parent, name AS child, 1 AS allow, + 'trusted writable query' AS reason + FROM queries + WHERE is_write = 1 + AND source IN ('config', 'plugin') + """ + + return PermissionSQL( + sql=f""" + WITH execute_sql_allowed AS ( + {sql} + ) + SELECT database_name AS parent, name AS child, 1 AS allow, + 'published query' AS reason + FROM queries + WHERE is_write = 0 + AND published = 1 + UNION ALL + SELECT q.database_name AS parent, q.name AS child, 1 AS allow, + 'execute-sql allows query' AS reason + FROM queries q + JOIN execute_sql_allowed es + ON es.parent = q.database_name + AND es.child IS NULL + WHERE q.is_write = 0 + AND q.published = 0 + {trusted_writable_sql} + """, + params=params, + ) diff --git a/datasette/resources.py b/datasette/resources.py index 236b3598..91a46d36 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A canned query in a database.""" + """A saved query in a database.""" name = "query" parent_class = DatabaseResource @@ -51,42 +51,8 @@ class QueryResource(Resource): @classmethod async def resources_sql(cls, datasette, actor=None) -> str: - from datasette.plugins import pm - from datasette.utils import await_me_maybe - - # Get all databases from catalog - db = datasette.get_internal_database() - result = await db.execute("SELECT database_name FROM catalog_databases") - databases = [row[0] for row in result.rows] - - # Gather canned queries for this actor from all databases. - # This keeps allowed_resources("view-query", actor=...) consistent with - # actor-specific canned_queries() implementations. - query_pairs = [] - for database_name in databases: - # Call the hook to get queries (including from config via default plugin) - for queries_result in pm.hook.canned_queries( - datasette=datasette, - database=database_name, - actor=actor, - ): - queries = await await_me_maybe(queries_result) - if queries: - for query_name in queries.keys(): - query_pairs.append((database_name, query_name)) - - # Build SQL - if not query_pairs: - return "SELECT NULL AS parent, NULL AS child WHERE 0" - - # Generate UNION ALL query - selects = [] - for db_name, query_name in query_pairs: - # Escape single quotes by doubling them - db_escaped = db_name.replace("'", "''") - query_escaped = query_name.replace("'", "''") - selects.append( - f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child" - ) - - return " UNION ALL ".join(selects) + return """ + SELECT q.database_name AS parent, q.name AS child + FROM queries q + JOIN catalog_databases cd ON cd.database_name = q.database_name + """ diff --git a/tests/test_queries.py b/tests/test_queries.py index d30fcfe7..dcebc2cd 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1,6 +1,7 @@ import pytest from datasette.app import Datasette +from datasette.resources import DatabaseResource, QueryResource @pytest.mark.asyncio @@ -121,3 +122,87 @@ async def test_update_query_only_updates_provided_fields(): assert query["on_success_redirect"] is None assert query["sql"] == "select 1" assert query["published"] is False + + +@pytest.mark.asyncio +async def test_config_queries_imported_to_internal_table(): + ds = Datasette( + memory=True, + config={ + "databases": { + "data": { + "queries": { + "configured": { + "sql": "select :name as name", + "title": "Configured query", + "params": ["name"], + } + } + } + } + }, + ) + ds.add_memory_database("query_config", name="data") + await ds.invoke_startup() + + assert await ds.get_query("data", "configured") == { + "database": "data", + "name": "configured", + "sql": "select :name as name", + "title": "Configured query", + "description": None, + "description_html": None, + "hide_sql": False, + "fragment": None, + "params": ["name"], + "parameters": ["name"], + "is_write": False, + "write": False, + "published": False, + "source": "config", + "owner_id": None, + "on_success_message": None, + "on_success_message_sql": None, + "on_success_redirect": None, + "on_error_message": None, + "on_error_redirect": None, + } + + +@pytest.mark.asyncio +async def test_query_resources_come_from_internal_table(): + ds = Datasette(memory=True) + ds.add_memory_database("query_resources", name="data") + await ds.invoke_startup() + await ds.add_query("data", "internal_query", "select 1", source="user") + + page = await ds.allowed_resources("view-query", actor=None) + + assert [(r.parent, r.child) for r in page.resources] == [ + ("data", "internal_query") + ] + + +@pytest.mark.asyncio +async def test_unpublished_query_requires_execute_sql_but_published_does_not(): + ds = Datasette(memory=True, settings={"default_allow_sql": False}) + ds.add_memory_database("query_permissions", name="data") + await ds.invoke_startup() + await ds.add_query("data", "unpublished", "select 1", published=False) + await ds.add_query("data", "published", "select 1", published=True) + + assert not await ds.allowed( + action="execute-sql", + resource=DatabaseResource("data"), + actor=None, + ) + assert not await ds.allowed( + action="view-query", + resource=QueryResource("data", "unpublished"), + actor=None, + ) + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "published"), + actor=None, + )