Add arbitrary write SQL execution page

Refs #2735
This commit is contained in:
Simon Willison 2026-05-25 08:30:49 -07:00
commit ef43c10388
8 changed files with 487 additions and 24 deletions

View file

@ -46,6 +46,7 @@ from .views import Context
from .views.database import (
database_download,
DatabaseView,
ExecuteWriteView,
TableCreateView,
QueryView,
QueryCreateView,
@ -1249,18 +1250,22 @@ 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):
async def ensure_query_write_permissions(
self, database, sql, *, actor=None, params=None, analysis=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
if analysis is None:
if params is None:
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)
@ -2547,6 +2552,10 @@ class Datasette:
QueryInsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/-/insert$",
)
add_route(
ExecuteWriteView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write$",
)
add_route(
DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",

View file

@ -48,6 +48,13 @@ def register_actions():
resource_class=DatabaseResource,
also_requires="view-database",
),
Action(
name="execute-write-sql",
abbr="ews",
description="Execute writable SQL queries",
resource_class=DatabaseResource,
also_requires="view-database",
),
Action(
name="create-table",
abbr="ct",

View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Execute write SQL{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% endblock %}
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Execute write SQL</h1>
{% if execution_message %}
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}</p>
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post">
<p><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
{% if parameter_names %}
<h2>Parameters</h2>
{% for parameter in parameter_names %}
<p><label for="qp{{ loop.index }}">{{ parameter }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}"></p>
{% endfor %}
{% endif %}
<h2>Analysis</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table>
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">required permission</th>
<th scope="col">Allowed</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td>{{ row.operation }}</td>
<td>{{ row.database }}</td>
<td>{{ row.table }}</td>
<td>{{ row.required_permission }}</td>
<td>{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}</td>
<td>{{ row.source or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
<p><input type="submit" value="Execute"{% if execute_disabled %} disabled{% endif %}></p>
</form>
{% include "_codemirror_foot.html" %}
{% endblock %}

View file

@ -30,6 +30,9 @@
{% if can_publish %}
<p><label><input type="checkbox" name="published" value="1"> Published</label></p>
{% endif %}
{% if sql and analysis_is_write %}
<p><a href="{{ urls.database(database) }}/-/execute-write?{{ {'sql': sql}|urlencode|safe }}">Execute write SQL</a></p>
{% endif %}
<h2>Analysis</h2>
{% if analysis_error %}

View file

@ -508,6 +508,27 @@ def _coerce_query_parameters(value, derived):
return parameters
def _analysis_is_write(analysis):
return any(
access.operation in {"insert", "update", "delete"}
for access in analysis.table_accesses
)
def _block_framing(response):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Frame-Options"] = "DENY"
return response
def _wants_json(request, is_json, data):
return (
is_json
or request.headers.get("accept") == "application/json"
or (isinstance(data, dict) and data.get("_json"))
)
async def _json_or_form_payload(request):
content_type = request.headers.get("content-type", "")
if content_type.startswith("application/json"):
@ -538,15 +559,14 @@ async def _analyze_user_query(datasette, db, sql, *, actor, published):
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = any(
access.operation in {"insert", "update", "delete"}
for access in analysis.table_accesses
)
is_write = _analysis_is_write(analysis)
if is_write:
if published:
raise QueryValidationError("Writable queries cannot be published")
try:
await datasette.ensure_query_write_permissions(db.name, sql, actor=actor)
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
else:
@ -575,6 +595,69 @@ def _analysis_rows(analysis):
]
async def _analysis_rows_with_permissions(datasette, analysis, actor):
rows = _analysis_rows(analysis)
for row in rows:
permission = row["required_permission"]
if permission:
row["allowed"] = await datasette.allowed(
action=permission,
resource=TableResource(row["database"], row["table"]),
actor=actor,
)
else:
row["allowed"] = None
return rows
def _coerce_execute_write_payload(data, is_json):
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
if is_json:
invalid_keys = set(data) - {"sql", "params"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
params = data.get("params") or {}
else:
params = {
key: value
for key, value in data.items()
if key not in {"sql", "csrftoken", "_json"}
}
if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary")
return data.get("sql"), params
async def _prepare_execute_write(datasette, db, sql, params, actor):
if not sql or not isinstance(sql, str):
raise QueryValidationError("SQL is required")
parameter_names = _derived_query_parameters(sql)
extra_params = set(params) - set(parameter_names)
if extra_params:
raise QueryValidationError(
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
)
params = {name: params.get(name, "") for name in parameter_names}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis):
raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes"
)
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
return parameter_names, params, analysis
def _apply_query_data_types(data):
typed = dict(data)
for key in ("hide_sql", "published"):
@ -707,6 +790,160 @@ async def _prepare_query_update(datasette, request, db, existing, update):
return update_kwargs
class ExecuteWriteView(BaseView):
name = "execute-write"
has_json_alternate = False
async def _render_form(
self,
request,
db,
*,
sql="",
parameter_values=None,
analysis=None,
analysis_error=None,
execution_message=None,
execution_ok=None,
status=200,
):
parameter_values = parameter_values or {}
parameter_names = []
analysis_rows = []
if sql and analysis_error is None:
try:
parameter_names = _derived_query_parameters(sql)
if analysis is None:
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
self.ds, analysis, request.actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
response = await self.render(
["execute_write.html"],
request,
{
"database": db.name,
"database_color": db.color,
"sql": sql,
"parameter_names": parameter_names,
"parameter_values": parameter_values,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"execution_message": execution_message,
"execution_ok": execution_ok,
"execute_disabled": bool(
(not sql)
or analysis_error
or any(row["allowed"] is False for row in analysis_rows)
),
},
)
response.status = status
return _block_framing(response)
async def get(self, request):
db = await self.ds.resolve_database(request)
await self.ds.ensure_permission(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
return await self._render_form(
request,
db,
sql=request.args.get("sql") or "",
)
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
)
if not db.is_mutable:
return _block_framing(_error(["Database is immutable"], 403))
data = {}
is_json = request.headers.get("content-type", "").startswith("application/json")
sql = ""
provided_params = {}
try:
data, is_json = await _json_or_form_payload(request)
sql, provided_params = _coerce_execute_write_payload(data, is_json)
parameter_names, params, analysis = await _prepare_execute_write(
self.ds, db, sql, provided_params, request.actor
)
except QueryValidationError as ex:
if _wants_json(request, is_json, data):
return _block_framing(_error([ex.message], ex.status))
return await self._render_form(
request,
db,
sql=sql or "",
parameter_values=provided_params,
analysis_error=ex.message,
execution_message=ex.message,
execution_ok=False,
status=ex.status,
)
try:
cursor = await db.execute_write(sql, params, request=request)
except sqlite3.DatabaseError as ex:
message = str(ex)
if _wants_json(request, is_json, data):
return _block_framing(_error([message], 400))
return await self._render_form(
request,
db,
sql=sql,
parameter_values=params,
analysis=analysis,
execution_message=message,
execution_ok=False,
status=400,
)
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
if _wants_json(request, is_json, data):
return _block_framing(
Response.json(
{
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"analysis": _analysis_rows(analysis),
}
)
)
return await self._render_form(
request,
db,
sql=sql,
parameter_values={name: params.get(name, "") for name in parameter_names},
analysis=analysis,
execution_message=message,
execution_ok=True,
)
class QueryListView(BaseView):
name = "query-list"
@ -753,18 +990,9 @@ class QueryCreateView(BaseView):
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
rows = _analysis_rows(analysis)
for row in rows:
permission = row["required_permission"]
if permission:
row["allowed"] = await self.ds.allowed(
action=permission,
resource=TableResource(row["database"], row["table"]),
actor=request.actor,
)
else:
row["allowed"] = None
analysis_rows = rows
analysis_rows = await _analysis_rows_with_permissions(
self.ds, analysis, request.actor
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
@ -783,6 +1011,10 @@ class QueryCreateView(BaseView):
),
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"analysis_is_write": bool(
analysis_rows
and any(row["required_permission"] for row in analysis_rows)
),
"save_disabled": bool(
analysis_error
or any(row["allowed"] is False for row in analysis_rows)

View file

@ -1423,13 +1423,23 @@ Actor is allowed to drop a database table.
execute-sql
-----------
Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
Actor is allowed to run arbitrary read-only SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.
.. _actions_execute_write_sql:
execute-write-sql
-----------------
Actor is allowed to run arbitrary writable SQL queries against a specific database, subject to table-level write permissions such as ``insert-row``, ``update-row`` and ``delete-row``.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_permissions_debug:
permissions-debug

View file

@ -526,6 +526,15 @@ Creating saved queries
``POST /<database>/-/queries/-/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database.
.. _ExecuteWriteView:
Executing write SQL
~~~~~~~~~~~~~~~~~~~
``GET /<database>/-/execute-write`` displays a form for executing writable SQL. A ``?sql=`` query string pre-populates the form without executing it.
``POST /<database>/-/execute-write`` executes writable SQL. This requires ``execute-write-sql`` for the database plus the relevant table-level write permissions.
.. _QueryDefinitionView:
Getting a saved query definition

View file

@ -212,6 +212,7 @@ async def test_query_actions_are_registered():
ds = Datasette()
await ds.invoke_startup()
assert ds.get_action("execute-write-sql").resource_class is DatabaseResource
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
@ -492,6 +493,127 @@ async def test_create_query_ui_and_arbitrary_sql_save_link():
assert "/data/-/queries/-/create?sql=select+%2A+from+dogs" in query_response.text
@pytest.mark.asyncio
async def test_execute_write_get_prepopulates_without_executing():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_get", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
response = await ds.client.get(
"/data/-/execute-write?sql=insert+into+dogs+(name)+values+('Cleo')",
actor={"id": "root"},
)
assert response.status_code == 200
assert response.headers["content-security-policy"] == "frame-ancestors 'none'"
assert response.headers["x-frame-options"] == "DENY"
assert "Execute write SQL" in response.text
assert 'action="/data/-/execute-write"' in response.text
assert "insert into dogs (name) values (&#39;Cleo&#39;)" in response.text
assert (await db.execute("select count(*) from dogs")).first()[0] == 0
@pytest.mark.asyncio
async def test_execute_write_post_requires_database_and_table_permissions():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-database": {"id": "writer"},
"execute-write-sql": {"id": "writer"},
}
}
}
},
)
db = ds.add_memory_database("execute_write_permissions", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
no_database_permission = await ds.client.post(
"/data/-/execute-write",
actor={"id": "outsider"},
json={
"sql": "insert into dogs (name) values (:name)",
"params": {"name": "Cleo"},
},
)
no_table_permission = await ds.client.post(
"/data/-/execute-write",
actor={"id": "writer"},
json={
"sql": "insert into dogs (name) values (:name)",
"params": {"name": "Cleo"},
},
)
assert no_database_permission.status_code == 403
assert no_database_permission.json()["errors"] == [
"Permission denied: need execute-write-sql"
]
assert no_table_permission.status_code == 403
assert no_table_permission.json()["errors"] == [
"Permission denied: need insert-row on data/dogs"
]
ds.config = {
"databases": {
"data": {
"permissions": {
"view-database": {"id": "writer"},
"execute-write-sql": {"id": "writer"},
},
"tables": {
"dogs": {
"permissions": {
"insert-row": {"id": "writer"},
}
}
},
}
}
}
allowed = await ds.client.post(
"/data/-/execute-write",
actor={"id": "writer"},
json={
"sql": "insert into dogs (name) values (:name)",
"params": {"name": "Cleo"},
},
)
assert allowed.status_code == 200
assert allowed.json()["ok"] is True
assert allowed.json()["rowcount"] == 1
assert allowed.json()["analysis"][0]["operation"] == "insert"
assert (await db.execute("select name from dogs")).first()[0] == "Cleo"
@pytest.mark.asyncio
async def test_execute_write_post_rejects_read_only_sql():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_read_only", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
json={"sql": "select * from dogs"},
)
assert response.status_code == 400
assert response.json()["errors"] == [
"Use /-/query for read-only SQL; this endpoint only executes writes"
]
@pytest.mark.asyncio
async def test_query_owner_gets_update_delete_and_writable_view_defaults():
ds = Datasette(memory=True, default_deny=True)