Expanded analysis of SQL operations, refs #2748

This commit is contained in:
Simon Willison 2026-05-26 22:11:06 -07:00
commit 737ff03efb
9 changed files with 740 additions and 120 deletions

View file

@ -12,10 +12,22 @@ import pytest
import pytest_asyncio
from datasette.app import Datasette
from datasette.permissions import PermissionSQL
from datasette.resources import TableResource
from datasette.resources import DatabaseResource, QueryResource, TableResource
from datasette import hookimpl
def test_resource_string_representations():
assert str(DatabaseResource("content")) == "content"
assert repr(DatabaseResource("content")) == (
"DatabaseResource(parent='content', child=None)"
)
assert str(TableResource("content", "dogs")) == "content/dogs"
assert repr(TableResource("content", "dogs")) == (
"TableResource(parent='content', child='dogs')"
)
assert str(QueryResource("content", "insert-a-dog")) == "content/insert-a-dog"
# Test plugin that provides permission rules
class PermissionRulesPlugin:
def __init__(self, rules_callback):

View file

@ -698,14 +698,17 @@ async def test_analyze_sql():
assert [
(
access.operation,
access.database,
access.sqlite_schema,
access.table,
access.columns,
access.source,
operation.operation,
operation.database,
operation.sqlite_schema,
operation.table,
operation.columns,
operation.source,
)
for access in analysis.table_accesses
for operation in analysis.operations
if operation.target_type == "table"
and operation.operation in {"read", "insert", "update", "delete"}
and not operation.internal
] == [
("read", "data", "main", "dogs", ("id", "name"), None),
]
@ -722,14 +725,17 @@ async def test_analyze_sql_insert_select():
assert {
(
access.operation,
access.database,
access.sqlite_schema,
access.table,
access.columns,
access.source,
operation.operation,
operation.database,
operation.sqlite_schema,
operation.table,
operation.columns,
operation.source,
)
for access in analysis.table_accesses
for operation in analysis.operations
if operation.target_type == "table"
and operation.operation in {"read", "insert", "update", "delete"}
and not operation.internal
} == {
("insert", "data", "main", "dogs", (), None),
("read", "data", "main", "cats", ("name",), None),

View file

@ -1643,6 +1643,172 @@ async def test_execute_write_post_requires_database_and_table_permissions():
assert (await db.execute("select name from dogs")).first()[0] == "Cleo"
@pytest.mark.asyncio
async def test_execute_write_create_table_uses_create_table_permission():
ds = Datasette(
memory=True,
default_deny=True,
config={
"permissions": {
"insert-row": {"id": "row-writer"},
"update-row": {"id": "row-writer"},
},
"databases": {
"data": {
"permissions": {
"view-database": {"id": ["creator", "row-writer"]},
"execute-write-sql": {"id": ["creator", "row-writer"]},
"create-table": {"id": "creator"},
}
}
},
},
)
db = ds.add_memory_database("execute_write_create_table", name="data")
await ds.invoke_startup()
analysis_response = await ds.client.get(
"/data/-/execute-write/analyze",
actor={"id": "creator"},
params={"sql": "create table foobar (id integer primary key, name text)"},
)
allowed_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "creator"},
json={"sql": "create table foobar (id integer primary key, name text)"},
)
row_permission_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "row-writer"},
json={"sql": "create table should_not_exist (id integer primary key)"},
)
assert analysis_response.status_code == 200
analysis_data = analysis_response.json()
assert analysis_data["ok"] is True
assert analysis_data["execute_disabled"] is False
assert analysis_data["analysis_rows"] == [
{
"operation": "create",
"database": "data",
"table": "foobar",
"required_permission": "create-table",
"source": None,
"allowed": True,
}
]
assert allowed_response.status_code == 200
assert allowed_response.json()["ok"] is True
assert allowed_response.json()["message"] == "Query executed"
assert await db.table_exists("foobar")
assert row_permission_response.status_code == 403
assert row_permission_response.json()["errors"] == [
"Permission denied: need create-table on data"
]
assert not await db.table_exists("should_not_exist")
@pytest.mark.asyncio
async def test_execute_write_alter_and_drop_table_use_schema_permissions():
ds = Datasette(
memory=True,
default_deny=True,
config={
"permissions": {
"delete-row": {"id": "row-writer"},
"update-row": {"id": "row-writer"},
},
"databases": {
"data": {
"permissions": {
"view-database": {"id": ["alterer", "dropper", "row-writer"]},
"execute-write-sql": {
"id": ["alterer", "dropper", "row-writer"]
},
},
"tables": {
"dogs": {
"permissions": {
"alter-table": {"id": "alterer"},
"drop-table": {"id": "dropper"},
}
}
},
}
},
},
)
db = ds.add_memory_database("execute_write_alter_drop_table", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await db.execute_write("create table cats (id integer primary key, name text)")
await ds.invoke_startup()
alter_allowed_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "alterer"},
json={"sql": "alter table dogs add column age integer"},
)
alter_row_permission_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "row-writer"},
json={"sql": "alter table cats add column age integer"},
)
assert alter_allowed_response.status_code == 200
assert "age" in [column.name for column in await db.table_column_details("dogs")]
assert alter_row_permission_response.status_code == 403
assert alter_row_permission_response.json()["errors"] == [
"Permission denied: need alter-table on data/cats"
]
assert "age" not in [
column.name for column in await db.table_column_details("cats")
]
create_index_allowed_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "alterer"},
json={"sql": "create index idx_dogs_name on dogs(name)"},
)
create_index_row_permission_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "row-writer"},
json={"sql": "create index idx_cats_name on cats(name)"},
)
drop_index_allowed_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "alterer"},
json={"sql": "drop index idx_dogs_name"},
)
assert create_index_allowed_response.status_code == 200
assert create_index_row_permission_response.status_code == 403
assert create_index_row_permission_response.json()["errors"] == [
"Permission denied: need alter-table on data/cats"
]
assert drop_index_allowed_response.status_code == 200
drop_allowed_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "dropper"},
json={"sql": "drop table dogs"},
)
drop_row_permission_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "row-writer"},
json={"sql": "drop table cats"},
)
assert drop_allowed_response.status_code == 200
assert not await db.table_exists("dogs")
assert drop_row_permission_response.status_code == 403
assert drop_row_permission_response.json()["errors"] == [
"Permission denied: need drop-table on data/cats"
]
assert await db.table_exists("cats")
@pytest.mark.asyncio
async def test_execute_write_insert_links_to_inserted_row():
ds = Datasette(memory=True, default_deny=True)

View file

@ -26,17 +26,20 @@ def conn():
conn.close()
def as_tuples(analysis):
def table_operation_tuples(analysis):
return [
(
access.operation,
access.database,
access.sqlite_schema,
access.table,
access.columns,
access.source,
operation.operation,
operation.database,
operation.sqlite_schema,
operation.table,
operation.columns,
operation.source,
)
for access in analysis.table_accesses
for operation in analysis.operations
if operation.target_type == "table"
and operation.operation in {"read", "insert", "update", "delete"}
and not operation.internal
]
@ -48,7 +51,7 @@ def test_analyze_select_tables(conn):
database_name="data",
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("read", "data", "main", "cats", ("id", "name"), None),
("read", "data", "main", "dogs", ("age", "id", "name"), None),
}
@ -57,11 +60,73 @@ def test_analyze_select_tables(conn):
def test_analyze_uses_sqlite_schema_as_default_database(conn):
analysis = analyze_sql_tables(conn, "select name from dogs")
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("read", "main", "main", "dogs", ("name",), None),
}
def operation_dict(operation):
return {
"operation": operation.operation,
"target_type": operation.target_type,
"database": operation.database,
"sqlite_schema": operation.sqlite_schema,
"table": operation.table,
"target": operation.target,
"columns": operation.columns,
"source": operation.source,
"internal": operation.internal,
}
def test_analyze_create_table_operation():
conn = sqlite3.connect(":memory:")
try:
analysis = analyze_sql_tables(
conn,
"create table foobar (id integer primary key, name text)",
database_name="data",
)
finally:
conn.close()
assert {
"operation": "create",
"target_type": "table",
"database": "data",
"sqlite_schema": "main",
"table": "foobar",
"target": "foobar",
"columns": (),
"source": None,
"internal": False,
} in [operation_dict(operation) for operation in analysis.operations]
assert not [
operation
for operation in analysis.operations
if operation.table in {"sqlite_master", "sqlite_schema"}
and not operation.internal
]
def test_analyze_transaction_operation(conn):
analysis = analyze_sql_tables(conn, "commit", database_name="data")
assert [operation_dict(operation) for operation in analysis.operations] == [
{
"operation": "commit",
"target_type": "transaction",
"database": None,
"sqlite_schema": None,
"table": None,
"target": "COMMIT",
"columns": (),
"source": None,
"internal": False,
}
]
def test_analyze_insert_tables(conn):
analysis = analyze_sql_tables(
conn,
@ -70,7 +135,7 @@ def test_analyze_insert_tables(conn):
database_name="data",
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("insert", "data", "main", "dogs", (), None),
("read", "data", "main", "dogs", ("id", "name"), "dogs_after_insert"),
("update", "data", "main", "cats", ("name",), "dogs_after_insert"),
@ -87,7 +152,7 @@ def test_analyze_update_tables(conn):
database_name="data",
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("update", "data", "main", "dogs", ("age",), None),
("read", "data", "main", "dogs", ("age", "name"), None),
}
@ -101,7 +166,7 @@ def test_analyze_delete_tables(conn):
database_name="data",
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("delete", "data", "main", "dogs", (), None),
("read", "data", "main", "dogs", ("name",), None),
}
@ -121,7 +186,7 @@ def test_analyze_insert_select_with_cte(conn):
database_name="data",
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("insert", "data", "main", "cats", (), None),
("read", "data", "main", "dogs", ("age", "name"), "old_dogs"),
}
@ -135,7 +200,7 @@ def test_analyze_view_with_instead_of_trigger(conn):
database_name="data",
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("update", "data", "main", "dog_names", ("name",), None),
("read", "data", "main", "dogs", ("id", "name"), "dog_names"),
("read", "data", "main", "dog_names", ("id", "name"), "dog_names"),
@ -163,7 +228,7 @@ def test_analyze_attached_database_tables(conn):
schema_to_database={"extra": "extra_db"},
)
assert set(as_tuples(analysis)) == {
assert set(table_operation_tuples(analysis)) == {
("insert", "extra_db", "extra", "people", (), None),
("read", "data", "main", "dogs", ("name",), None),
}