From 2f84ab77f2bb958aed29dbfbe0a7c7d8e3a34039 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 2 Jul 2026 08:56:04 -0700 Subject: [PATCH] Support CREATE VIEW / DROP VIEW in execute-write-sql New create-view and drop-view actions. Also fix a related bug in analyze_sql_tables(): SQLite's authorizer fires a spurious SQLITE_DELETE callback against the view name when a view is dropped (the same thing it does for dropped tables), which was incorrectly surfaced as a delete-row requirement on the view. Broaden the existing drop-table-delete suppression to cover dropped views too. Closes #2819 --- datasette/default_actions.py | 12 +++++ datasette/utils/sql_analysis.py | 6 +-- datasette/write_sql.py | 27 ++++++++++ docs/authentication.rst | 24 ++++++++- tests/test_queries.py | 92 ++++++++++++++++++++++++++++++++- 5 files changed, 155 insertions(+), 6 deletions(-) diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 2f78570b..602e0df4 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -61,6 +61,12 @@ def register_actions(): description="Create tables", resource_class=DatabaseResource, ), + Action( + name="create-view", + abbr="cv", + description="Create views", + resource_class=DatabaseResource, + ), Action( name="store-query", abbr="sq", @@ -111,6 +117,12 @@ def register_actions(): description="Drop tables", resource_class=TableResource, ), + Action( + name="drop-view", + abbr="dv", + description="Drop views", + resource_class=TableResource, + ), # Query-level actions (child-level) Action( name="view-query", diff --git a/datasette/utils/sql_analysis.py b/datasette/utils/sql_analysis.py index 3a509bd2..1be28982 100644 --- a/datasette/utils/sql_analysis.py +++ b/datasette/utils/sql_analysis.py @@ -488,17 +488,17 @@ def analyze_sql_tables( and key.operation in {"create", "alter", "drop"} for key in operations ) - dropped_tables = { + dropped_tables_and_views = { (key.database, key.table) for key in operations - if key.operation == "drop" and key.target_type == "table" + if key.operation == "drop" and key.target_type in {"table", "view"} } def key_is_drop_table_delete(key: OperationKey) -> bool: return ( key.operation == "delete" and key.target_type == "table" - and (key.database, key.table) in dropped_tables + and (key.database, key.table) in dropped_tables_and_views ) has_user_table_access_in_schema_operation = any( diff --git a/datasette/write_sql.py b/datasette/write_sql.py index cdc0c6d3..ca144a72 100644 --- a/datasette/write_sql.py +++ b/datasette/write_sql.py @@ -138,6 +138,33 @@ def decision_for_write_sql_operation( ), ) ) + if operation.operation == "create" and operation.target_type == "view": + if operation.database is None: + return UnsupportedWriteSqlOperation(unsupported_message) + return RequireWriteSqlPermissions( + ( + PermissionRequirement( + action="create-view", + resource=DatabaseResource(database=operation.database), + ), + ) + ) + if ( + operation.operation == "drop" + and operation.target_type == "view" + and operation.database is not None + and operation.table is not None + ): + return RequireWriteSqlPermissions( + ( + PermissionRequirement( + action="drop-view", + resource=TableResource( + database=operation.database, table=operation.table + ), + ), + ) + ) if ( operation.operation == "alter" and operation.target_type == "table" diff --git a/docs/authentication.rst b/docs/authentication.rst index 8101699c..304b589f 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -50,7 +50,7 @@ The one exception is the "root" account, which you can sign into while using Dat The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including: * All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.) -* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``) +* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``) * Debug permissions (``permissions-debug``, ``debug-menu``) * Any custom permissions defined by plugins @@ -1386,6 +1386,16 @@ create-table Actor is allowed to create a database table. +``resource`` - ``datasette.resources.DatabaseResource(database)`` + ``database`` is the name of the database (string) + +.. _actions_create_view: + +create-view +----------- + +Actor is allowed to create a database view. + ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) @@ -1425,6 +1435,18 @@ Actor is allowed to drop a database table. ``table`` is the name of the table (string) +.. _actions_drop_view: + +drop-view +--------- + +Actor is allowed to drop a database view. + +``resource`` - ``datasette.resources.TableResource(database, table)`` + ``database`` is the name of the database (string) + + ``table`` is the name of the view (string) + .. _actions_execute_sql: execute-sql diff --git a/tests/test_queries.py b/tests/test_queries.py index 6dfcc8b7..6d4275a3 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -3214,6 +3214,74 @@ async def test_execute_write_create_table_uses_create_table_permission(): assert not await db.table_exists("should_not_exist") +@pytest.mark.asyncio +async def test_execute_write_create_view_uses_create_view_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-view": {"id": "creator"}, + } + } + }, + }, + ) + db = ds.add_memory_database("execute_write_create_view", name="data") + await db.execute_write("create table dogs (id integer primary key, name text)") + await ds.invoke_startup() + + analysis_response = await ds.client.get( + "/data/-/execute-write/analyze", + actor={"id": "creator"}, + params={"sql": "create view dog_names as select id, name from dogs"}, + ) + allowed_response = await ds.client.post( + "/data/-/execute-write", + actor={"id": "creator"}, + json={"sql": "create view dog_names as select id, name from dogs"}, + ) + row_permission_response = await ds.client.post( + "/data/-/execute-write", + actor={"id": "row-writer"}, + json={"sql": "create view should_not_exist as select id from dogs"}, + ) + + 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": "dog_names", + "required_permission": "create-view", + "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.view_exists("dog_names") + + assert row_permission_response.status_code == 403 + assert row_permission_response.json()["errors"] == [ + "Permission denied: need create-view on data" + ] + assert not await db.view_exists("should_not_exist") + + @pytest.mark.parametrize( ( "database_name", @@ -3264,8 +3332,20 @@ async def test_execute_write_create_table_uses_create_table_permission(): (), "drop-table", ), + ( + "execute_write_drop_view", + "dropper", + "drop view dogs_view", + "drop view cats_view", + "Permission denied: need drop-view on data/cats_view", + ( + "create view dogs_view as select * from dogs", + "create view cats_view as select * from cats", + ), + "drop-view", + ), ), - ids=("alter-table", "create-index", "drop-index", "drop-table"), + ids=("alter-table", "create-index", "drop-index", "drop-table", "drop-view"), ) @pytest.mark.asyncio async def test_execute_write_schema_operations_use_schema_permissions( @@ -3300,7 +3380,12 @@ async def test_execute_write_schema_operations_use_schema_permissions( "drop-table": {"id": "dropper"}, "view-table": {"id": "alterer"}, } - } + }, + "dogs_view": { + "permissions": { + "drop-view": {"id": "dropper"}, + } + }, }, } }, @@ -3353,6 +3438,9 @@ async def test_execute_write_schema_operations_use_schema_permissions( elif expected_state == "drop-table": assert not await db.table_exists("dogs") assert await db.table_exists("cats") + elif expected_state == "drop-view": + assert not await db.view_exists("dogs_view") + assert await db.view_exists("cats_view") @pytest.mark.asyncio