Migrate view-query permission to SQL-based system, refs #2510

This change integrates canned queries with Datasette's new SQL-based
permissions system by making the following changes:

1. **Default canned_queries plugin hook**: Added a new hookimpl in
   default_permissions.py that returns canned queries from datasette
   configuration. This extracts config-reading logic into a plugin hook,
   allowing QueryResource to discover all queries.

2. **Async resources_sql()**: Converted Resource.resources_sql() from a
   synchronous class method returning a string to an async method that
   receives the datasette instance. This allows QueryResource to call
   plugin hooks and query the database.

3. **QueryResource implementation**: Implemented QueryResource.resources_sql()
   to gather all canned queries by:
   - Querying catalog_databases for all databases
   - Calling canned_queries hooks for each database with actor=None
   - Building a UNION ALL SQL query of all (database, query_name) pairs
   - Properly escaping single quotes in resource names

4. **Simplified get_canned_queries()**: Removed config-reading logic since
   it's now handled by the default plugin hook.

5. **Added view-query to default allow**: Added "view-query" to the
   default_allow_actions set so canned queries are accessible by default.

6. **Removed xfail markers**: Removed test xfail markers from:
   - tests/test_canned_queries.py (entire module)
   - tests/test_html.py (2 tests)
   - tests/test_permissions.py (1 test)
   - tests/test_plugins.py (1 test)

All canned query tests now pass with the new permission system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2025-10-25 10:21:50 -07:00
commit 82cc3d5c86
10 changed files with 56 additions and 82 deletions

View file

@ -922,9 +922,7 @@ class Datasette:
return self._app_css_hash
async def get_canned_queries(self, database_name, actor):
queries = (
((self.config or {}).get("databases") or {}).get(database_name) or {}
).get("queries") or {}
queries = {}
for more_queries in pm.hook.canned_queries(
datasette=self,
database=database_name,

View file

@ -50,6 +50,7 @@ async def permission_resources_sql(datasette, actor, action):
"view-database",
"view-database-download",
"view-table",
"view-query",
"execute-sql",
}
if action in default_allow_actions:
@ -335,3 +336,12 @@ def skip_csrf(scope):
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True
@hookimpl
def canned_queries(datasette, database, actor):
"""Return canned queries from datasette configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -13,7 +13,7 @@ class InstanceResource(Resource):
super().__init__(parent=None, child=None)
@classmethod
def resources_sql(cls) -> str:
async def resources_sql(cls, datasette) -> str:
return "SELECT NULL AS parent, NULL AS child"
@ -27,7 +27,7 @@ class DatabaseResource(Resource):
super().__init__(parent=database, child=None)
@classmethod
def resources_sql(cls) -> str:
async def resources_sql(cls, datasette) -> str:
return """
SELECT database_name AS parent, NULL AS child
FROM catalog_databases
@ -44,7 +44,7 @@ class TableResource(Resource):
super().__init__(parent=database, child=table)
@classmethod
def resources_sql(cls) -> str:
async def resources_sql(cls, datasette) -> str:
return """
SELECT database_name AS parent, table_name AS child
FROM catalog_tables
@ -64,6 +64,41 @@ class QueryResource(Resource):
super().__init__(parent=database, child=query)
@classmethod
def resources_sql(cls) -> str:
# TODO: Need catalog for queries
return "SELECT NULL AS parent, NULL AS child WHERE 0"
async def resources_sql(cls, datasette) -> 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 all canned queries from all databases
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=None, # Get ALL queries for resource enumeration
):
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)

View file

@ -177,7 +177,7 @@ async def _build_single_action_sql(
raise ValueError(f"Unknown action: {action}")
# Get base resources SQL from the resource class
base_resources_sql = action_obj.resource_class.resources_sql()
base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
# Get all permission rule fragments from plugins via the hook
rule_results = pm.hook.permission_resources_sql(