Load saved queries into permission resources

Refs #2735
This commit is contained in:
Simon Willison 2026-05-24 22:40:22 -07:00
commit b4c63966f8
5 changed files with 179 additions and 60 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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
"""

View file

@ -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,
)