Add query management actions and write analysis

Refs #2735
This commit is contained in:
Simon Willison 2026-05-24 22:41:56 -07:00
commit 221be2632e
3 changed files with 129 additions and 0 deletions

View file

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

View file

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

View file

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