diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 7d8dd37d..b0640ae8 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable canned query demo + - name: And the counters writable stored query demo run: | cat > plugins/counters.py < Environment: environment = self._jinja_env if request: @@ -731,6 +751,7 @@ class Datasette: await await_me_maybe(hook) # Ensure internal tables and metadata are populated before startup hooks await self._refresh_schemas() + await self._save_queries_from_config() # Load column_types from config into internal DB await self._apply_column_types_config() for hook in pm.hook.startup(datasette=self): @@ -1007,6 +1028,179 @@ class Datasette: [database_name, resource_name, column_name, key, value], ) + @staticmethod + def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None: + return stored_queries.query_row_to_stored_query(row) + + @staticmethod + def _query_options_json(options): + return stored_queries.query_options_json(options) + + async def add_query( + self, + database: str, + name: str, + sql: str, + *, + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, + ) -> None: + return await stored_queries.add_query( + self, + database, + name, + sql, + title=title, + description=description, + description_html=description_html, + hide_sql=hide_sql, + fragment=fragment, + parameters=parameters, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + on_success_message=on_success_message, + on_success_message_sql=on_success_message_sql, + on_success_redirect=on_success_redirect, + on_error_message=on_error_message, + on_error_redirect=on_error_redirect, + replace=replace, + ) + + async def update_query( + self, + database: str, + name: str, + *, + sql=stored_queries.UNCHANGED, + title=stored_queries.UNCHANGED, + description=stored_queries.UNCHANGED, + description_html=stored_queries.UNCHANGED, + hide_sql=stored_queries.UNCHANGED, + fragment=stored_queries.UNCHANGED, + parameters=stored_queries.UNCHANGED, + is_write=stored_queries.UNCHANGED, + is_private=stored_queries.UNCHANGED, + is_trusted=stored_queries.UNCHANGED, + source=stored_queries.UNCHANGED, + owner_id=stored_queries.UNCHANGED, + on_success_message=stored_queries.UNCHANGED, + on_success_message_sql=stored_queries.UNCHANGED, + on_success_redirect=stored_queries.UNCHANGED, + on_error_message=stored_queries.UNCHANGED, + on_error_redirect=stored_queries.UNCHANGED, + ) -> None: + return await stored_queries.update_query( + self, + database, + name, + sql=sql, + title=title, + description=description, + description_html=description_html, + hide_sql=hide_sql, + fragment=fragment, + parameters=parameters, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + on_success_message=on_success_message, + on_success_message_sql=on_success_message_sql, + on_success_redirect=on_success_redirect, + on_error_message=on_error_message, + on_error_redirect=on_error_redirect, + ) + + async def remove_query( + self, database: str, name: str, source: str | None = None + ) -> None: + return await stored_queries.remove_query(self, database, name, source=source) + + async def get_query( + self, database: str, name: str + ) -> stored_queries.StoredQuery | None: + return await stored_queries.get_query(self, database, name) + + async def count_queries( + self, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + ) -> int: + return await stored_queries.count_queries( + self, + database, + actor=actor, + q=q, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + ) + + async def list_queries( + self, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, + ) -> stored_queries.StoredQueryPage: + return await stored_queries.list_queries( + self, + database, + actor=actor, + limit=limit, + cursor=cursor, + q=q, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + include_private=include_private, + ) + + async def ensure_query_write_permissions( + self, database, sql, *, actor=None, params=None, analysis=None + ): + return await stored_queries.ensure_query_write_permissions( + self, database, sql, actor=actor, params=params, analysis=analysis + ) + # Column types API async def _get_resource_column_details(self, database: str, resource: str): @@ -1238,29 +1432,6 @@ class Datasette: def app_css_hash(self): return self.static_hash("app.css") - async def get_canned_queries(self, database_name, actor): - queries = {} - for more_queries in pm.hook.canned_queries( - datasette=self, - database=database_name, - actor=actor, - ): - more_queries = await await_me_maybe(more_queries) - queries.update(more_queries or {}) - # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}} - for key in queries: - if not isinstance(queries[key], dict): - queries[key] = {"sql": queries[key]} - # Also make sure "name" is available: - queries[key]["name"] = key - return queries - - async def get_canned_query(self, database_name, query_name, actor): - queries = await self.get_canned_queries(database_name, actor) - query = queries.get(query_name) - if query: - return query - def _prepare_connection(self, conn, database): conn.row_factory = sqlite3.Row conn.text_factory = lambda x: str(x, "utf-8", "replace") @@ -2236,6 +2407,10 @@ class Datasette: JumpView.as_view(self), r"/-/jump(\.(?Pjson))?$", ) + add_route( + GlobalQueryListView.as_view(self), + r"/-/queries(\.(?Pjson))?$", + ) add_route( InstanceSchemaView.as_view(self), r"/-/schema(\.(?Pjson|md))?$", @@ -2281,14 +2456,50 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") + add_route( + QueryListView.as_view(self), + r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", + ) + add_route( + QueryCreateAnalyzeView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/analyze$", + ) + add_route( + QueryStoreView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/store$", + ) + add_route( + ExecuteWriteAnalyzeView.as_view(self), + r"/(?P[^\/\.]+)/-/execute-write/analyze$", + ) + add_route( + ExecuteWriteView.as_view(self), + r"/(?P[^\/\.]+)/-/execute-write$", + ) add_route( DatabaseSchemaView.as_view(self), r"/(?P[^\/\.]+)/-/schema(\.(?Pjson|md))?$", ) + add_route( + QueryParametersView.as_view(self), + r"/(?P[^\/\.]+)/-/query/parameters$", + ) add_route( wrap_view(QueryView, self), r"/(?P[^\/\.]+)/-/query(\.(?P\w+))?$", ) + add_route( + QueryDefinitionView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/definition$", + ) + add_route( + QueryUpdateView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/update$", + ) + add_route( + QueryDeleteView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/delete$", + ) add_route( wrap_view(table_view, self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)(\.(?P\w+))?$", diff --git a/datasette/database.py b/datasette/database.py index 66d50ffa..e7e9527e 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -25,6 +25,7 @@ from .utils import ( table_columns, table_column_details, ) +from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables from .utils.sqlite import sqlite_version from .inspect import inspect_hash @@ -301,6 +302,13 @@ class Database: # Threaded mode - send to write thread return await self._send_to_write_thread(fn, isolated_connection=True) + async def analyze_sql(self, sql, params=None) -> SQLAnalysis: + self._check_not_closed() + + return await self.execute_isolated_fn( + lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name) + ) + async def execute_write_fn(self, fn, block=True, transaction=True, request=None): self._check_not_closed() pending_events = [] diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 149a4e5f..2f78570b 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -48,12 +48,26 @@ 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", description="Create tables", resource_class=DatabaseResource, ), + Action( + name="store-query", + abbr="sq", + description="Create stored queries", + resource_class=DatabaseResource, + also_requires="execute-sql", + ), # 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 stored queries", + resource_class=QueryResource, + ), + Action( + name="delete-query", + abbr="dq", + description="Delete stored queries", + resource_class=QueryResource, + ), ) diff --git a/datasette/default_database_actions.py b/datasette/default_database_actions.py new file mode 100644 index 00000000..e0cb3cdf --- /dev/null +++ b/datasette/default_database_actions.py @@ -0,0 +1,24 @@ +from datasette import hookimpl +from datasette.resources import DatabaseResource + + +@hookimpl +def database_actions(datasette, actor, database, request): + async def inner(): + if not datasette.get_database(database).is_mutable: + return [] + if not await datasette.allowed( + action="execute-write-sql", + resource=DatabaseResource(database), + actor=actor, + ): + return [] + return [ + { + "href": datasette.urls.database(database) + "/-/execute-write", + "label": "Execute write SQL", + "description": "Run writable SQL with table permission checks.", + } + ] + + return inner diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index 9e3bb648..6cd46f04 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -17,13 +17,6 @@ UNION/INTERSECT operations. The order of evaluation is: from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl - # Re-export all hooks and public utilities from .restrictions import ( actor_restrictions_sql as actor_restrictions_sql, @@ -33,16 +26,9 @@ from .restrictions import ( from .root import root_user_permissions_sql as root_user_permissions_sql from .config import config_permissions_sql as config_permissions_sql from .defaults import ( + # Avoid "datasette.default_permissions" does not explicitly export attribute default_allow_sql_check as default_allow_sql_check, default_action_permissions_sql as default_action_permissions_sql, + default_query_permissions_sql as default_query_permissions_sql, DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, ) - - -@hookimpl -def canned_queries(datasette: "Datasette", database: str, actor) -> dict: - """Return canned queries defined in datasette.yaml configuration.""" - queries = ( - ((datasette.config or {}).get("databases") or {}).get(database) or {} - ).get("queries") or {} - return queries diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 4c74219d..5bc74425 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -67,3 +67,48 @@ async def default_action_permissions_sql( return PermissionSQL.allow(reason=reason) return None + + +@hookimpl(specname="permission_resources_sql") +async def default_query_permissions_sql( + datasette: "Datasette", + actor: Optional[dict], + action: str, +) -> Optional[PermissionSQL]: + actor_id = actor.get("id") if isinstance(actor, dict) else None + + if action not in {"view-query", "update-query", "delete-query"}: + return None + + params = {"query_owner_id": actor_id} + rule_sqls = [] + if actor_id is not None: + if action in {"update-query", "delete-query"}: + # Query owner can update/delete query + rule_sqls.append(""" + SELECT database_name AS parent, name AS child, 1 AS allow, + 'query owner' AS reason + FROM queries + WHERE source = 'user' + AND owner_id = :query_owner_id + """) + else: + # Query owner can view-query + rule_sqls.append(""" + SELECT database_name AS parent, name AS child, 1 AS allow, + 'query owner' AS reason + FROM queries + WHERE owner_id = :query_owner_id + """) + + # restriction_sql enforces private queries ONLY visible/mutable by owner + return PermissionSQL( + sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, + restriction_sql=""" + SELECT database_name AS parent, name AS child + FROM queries + WHERE is_private = 0 + OR owner_id = :query_owner_id + """, + params=params, + ) diff --git a/datasette/facets.py b/datasette/facets.py index bc4b6904..abe0605e 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -83,7 +83,7 @@ class Facet: self.ds = ds self.request = request self.database = database - # For foreign key expansion. Can be None for e.g. canned SQL queries: + # For foreign key expansion. Can be None for e.g. stored SQL queries: self.table = table self.sql = sql or f"select * from [{table}]" self.params = params or [] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index cf95abcb..dcd502af 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -137,11 +137,6 @@ def permission_resources_sql(datasette, actor, action): """ -@hookspec -def canned_queries(datasette, database, actor): - """Return a dictionary of canned query definitions or an awaitable function that returns them""" - - @hookspec def register_magic_parameters(datasette): """Return a list of (name, function) magic parameter functions""" @@ -179,7 +174,7 @@ def view_actions(datasette, actor, database, view, request): @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Links for the query and canned query actions menu""" + """Links for the query and stored query actions menu""" @hookspec @@ -233,8 +228,8 @@ def top_query(datasette, request, database, sql): @hookspec -def top_canned_query(datasette, request, database, query_name): - """HTML to include at the top of the canned query page""" +def top_stored_query(datasette, request, database, query_name): + """HTML to include at the top of the stored query page""" @hookspec diff --git a/datasette/plugins.py b/datasette/plugins.py index f532ac60..5a31cdad 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -30,6 +30,7 @@ DEFAULT_PLUGINS = ( "datasette.blob_renderer", "datasette.default_debug_menu", "datasette.default_jump_items", + "datasette.default_database_actions", "datasette.handle_exception", "datasette.forbidden", "datasette.events", diff --git a/datasette/resources.py b/datasette/resources.py index 236b3598..ee2e6d98 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A canned query in a database.""" + """A stored query in a database.""" name = "query" parent_class = DatabaseResource @@ -51,42 +51,8 @@ class QueryResource(Resource): @classmethod async def resources_sql(cls, datasette, actor=None) -> 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 canned queries for this actor from all databases. - # This keeps allowed_resources("view-query", actor=...) consistent with - # actor-specific canned_queries() implementations. - 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=actor, - ): - 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) + return """ + SELECT q.database_name AS parent, q.name AS child + FROM queries q + JOIN catalog_databases cd ON cd.database_name = q.database_name + """ diff --git a/datasette/static/app.css b/datasette/static/app.css index c21d0dc4..815f6db8 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1409,11 +1409,15 @@ svg.dropdown-menu-icon { border-bottom: 5px solid #666; } -.canned-query-edit-sql { +.stored-query-edit-sql { padding-left: 0.5em; position: relative; top: 1px; } +.save-query { + display: inline-block; + margin-left: 0.45em; +} .blob-download { display: block; diff --git a/datasette/stored_queries.py b/datasette/stored_queries.py new file mode 100644 index 00000000..bcfdfdb4 --- /dev/null +++ b/datasette/stored_queries.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any, Iterable + +from .resources import TableResource +from .utils import named_parameters, sqlite3, tilde_encode, urlsafe_components +from .utils.asgi import Forbidden + +UNCHANGED = object() + +QUERY_OPTION_FIELDS = ( + "hide_sql", + "fragment", + "on_success_message", + "on_success_message_sql", + "on_success_redirect", + "on_error_message", + "on_error_redirect", +) + + +@dataclass +class StoredQuery: + database: str + name: str + sql: str + title: str | None + description: str | None + description_html: str | None + hide_sql: bool + fragment: str | None + parameters: list[str] + is_write: bool + is_private: bool + is_trusted: bool + source: str + owner_id: str | None + on_success_message: str | None + on_success_message_sql: str | None + on_success_redirect: str | None + on_error_message: str | None + on_error_redirect: str | None + private: bool | None = None + + +@dataclass +class StoredQueryPage: + queries: list[StoredQuery] + next: str | None + has_more: bool + limit: int + + +def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]: + data = { + "database": query.database, + "name": query.name, + "sql": query.sql, + "title": query.title, + "description": query.description, + "description_html": query.description_html, + "hide_sql": query.hide_sql, + "fragment": query.fragment, + "params": list(query.parameters), + "parameters": list(query.parameters), + "is_write": query.is_write, + "is_private": query.is_private, + "is_trusted": query.is_trusted, + "source": query.source, + "owner_id": query.owner_id, + "on_success_message": query.on_success_message, + "on_success_message_sql": query.on_success_message_sql, + "on_success_redirect": query.on_success_redirect, + "on_error_message": query.on_error_message, + "on_error_redirect": query.on_error_redirect, + } + if query.private is not None: + data["private"] = query.private + return data + + +def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]: + return { + "queries": [stored_query_to_dict(query) for query in page.queries], + "next": page.next, + "has_more": page.has_more, + "limit": page.limit, + } + + +async def save_queries_from_config(datasette: Any) -> None: + # Apply configured query entries from datasette.yaml to the internal table. + await datasette.get_internal_database().execute_write( + "DELETE FROM queries WHERE source = 'config'" + ) + for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items(): + for query_name, query_config in (db_config.get("queries") or {}).items(): + if not isinstance(query_config, dict): + query_config = {"sql": query_config} + await datasette.add_query( + dbname, + query_name, + query_config["sql"], + title=query_config.get("title"), + description=query_config.get("description"), + description_html=query_config.get("description_html"), + hide_sql=bool(query_config.get("hide_sql")), + fragment=query_config.get("fragment"), + parameters=query_config.get("params"), + is_write=bool(query_config.get("write")), + is_private=bool(query_config.get("is_private")), + is_trusted=bool(query_config.get("is_trusted", True)), + source="config", + on_success_message=query_config.get("on_success_message"), + on_success_message_sql=query_config.get("on_success_message_sql"), + on_success_redirect=query_config.get("on_success_redirect"), + on_error_message=query_config.get("on_error_message"), + on_error_redirect=query_config.get("on_error_redirect"), + ) + + +def query_row_to_stored_query( + row: Any, private: bool | None = None +) -> StoredQuery | None: + if row is None: + return None + parameters = json.loads(row["parameters"] or "[]") + options = json.loads(row["options"] or "{}") + return StoredQuery( + database=row["database_name"], + name=row["name"], + sql=row["sql"], + title=row["title"], + description=row["description"], + description_html=row["description_html"], + hide_sql=bool(options.get("hide_sql")), + fragment=options.get("fragment"), + parameters=parameters, + is_write=bool(row["is_write"]), + is_private=bool(row["is_private"]), + is_trusted=bool(row["is_trusted"]), + source=row["source"], + owner_id=row["owner_id"], + on_success_message=options.get("on_success_message"), + on_success_message_sql=options.get("on_success_message_sql"), + on_success_redirect=options.get("on_success_redirect"), + on_error_message=options.get("on_error_message"), + on_error_redirect=options.get("on_error_redirect"), + private=private, + ) + + +def query_options_json(options: dict[str, Any]) -> str: + options_dict = {} + for field in QUERY_OPTION_FIELDS: + value = options.get(field) + if field == "hide_sql": + if value: + options_dict[field] = True + elif value is not None: + options_dict[field] = value + return json.dumps(options_dict, sort_keys=True) + + +async def add_query( + datasette: Any, + database: str, + name: str, + sql: str, + *, + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, +) -> None: + parameters_json = json.dumps(list(parameters or [])) + options_json = query_options_json( + { + "hide_sql": hide_sql, + "fragment": fragment, + "on_success_message": on_success_message, + "on_success_message_sql": on_success_message_sql, + "on_success_redirect": on_success_redirect, + "on_error_message": on_error_message, + "on_error_redirect": on_error_redirect, + } + ) + sql_statement = """ + INSERT INTO queries ( + database_name, name, sql, title, description, description_html, + options, parameters, is_write, is_private, is_trusted, source, owner_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + if replace: + sql_statement += """ + ON CONFLICT(database_name, name) DO UPDATE SET + sql = excluded.sql, + title = excluded.title, + description = excluded.description, + description_html = excluded.description_html, + options = excluded.options, + parameters = excluded.parameters, + is_write = excluded.is_write, + is_private = excluded.is_private, + is_trusted = excluded.is_trusted, + source = excluded.source, + owner_id = excluded.owner_id, + updated_at = CURRENT_TIMESTAMP + """ + await datasette.get_internal_database().execute_write( + sql_statement, + [ + database, + name, + sql, + title, + description, + description_html, + options_json, + parameters_json, + int(bool(is_write)), + int(bool(is_private)), + int(bool(is_trusted)), + source, + owner_id, + ], + ) + + +async def update_query( + datasette: Any, + database: str, + name: str, + *, + sql=UNCHANGED, + title=UNCHANGED, + description=UNCHANGED, + description_html=UNCHANGED, + hide_sql=UNCHANGED, + fragment=UNCHANGED, + parameters=UNCHANGED, + is_write=UNCHANGED, + is_private=UNCHANGED, + is_trusted=UNCHANGED, + source=UNCHANGED, + owner_id=UNCHANGED, + on_success_message=UNCHANGED, + on_success_message_sql=UNCHANGED, + on_success_redirect=UNCHANGED, + on_error_message=UNCHANGED, + on_error_redirect=UNCHANGED, +) -> None: + fields = { + "sql": sql, + "title": title, + "description": description, + "description_html": description_html, + "parameters": parameters, + "is_write": is_write, + "is_private": is_private, + "is_trusted": is_trusted, + "source": source, + "owner_id": owner_id, + } + option_fields = { + "hide_sql": hide_sql, + "fragment": fragment, + "on_success_message": on_success_message, + "on_success_message_sql": on_success_message_sql, + "on_success_redirect": on_success_redirect, + "on_error_message": on_error_message, + "on_error_redirect": on_error_redirect, + } + updates = [] + params = [] + for field, value in fields.items(): + if value is UNCHANGED: + continue + if field in {"is_write", "is_private", "is_trusted"}: + value = int(bool(value)) + elif field == "parameters": + value = json.dumps(list(value or [])) + updates.append(f"{field} = ?") + params.append(value) + changed_options = { + field: value for field, value in option_fields.items() if value is not UNCHANGED + } + if changed_options: + rows = await datasette.get_internal_database().execute( + """ + SELECT options FROM queries + WHERE database_name = ? AND name = ? + """, + [database, name], + ) + row = rows.first() + options = json.loads(row["options"] or "{}") if row is not None else {} + for field, value in changed_options.items(): + if field == "hide_sql": + if value: + options[field] = True + else: + options.pop(field, None) + elif value is None: + options.pop(field, None) + else: + options[field] = value + updates.append("options = ?") + params.append(json.dumps(options, sort_keys=True)) + if not updates: + return + updates.append("updated_at = CURRENT_TIMESTAMP") + params.extend([database, name]) + await datasette.get_internal_database().execute_write( + """ + UPDATE queries + SET {} + WHERE database_name = ? AND name = ? + """.format(", ".join(updates)), + params, + ) + + +async def remove_query( + datasette: Any, database: str, name: str, source: str | None = None +) -> None: + sql = "DELETE FROM queries WHERE database_name = ? AND name = ?" + params = [database, name] + if source is not None: + sql += " AND source = ?" + params.append(source) + await datasette.get_internal_database().execute_write(sql, params) + + +async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None: + rows = await datasette.get_internal_database().execute( + """ + SELECT * FROM queries + WHERE database_name = ? AND name = ? + """, + [database, name], + ) + return query_row_to_stored_query(rows.first()) + + +async def count_queries( + datasette: Any, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, +) -> int: + allowed_sql, allowed_params = await datasette.allowed_resources_sql( + action="view-query", + actor=actor, + parent=database, + ) + params = dict(allowed_params) + where_clauses = [] + if database is not None: + params["query_database"] = database + where_clauses.append("q.database_name = :query_database") + + if q: + where_clauses.append(""" + ( + q.name LIKE :query_search + OR q.title LIKE :query_search + OR q.description LIKE :query_search + OR q.sql LIKE :query_search + ) + """) + params["query_search"] = "%{}%".format(q) + if is_write is not None: + where_clauses.append("q.is_write = :query_is_write") + params["query_is_write"] = int(bool(is_write)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) + if source is not None: + where_clauses.append("q.source = :query_source") + params["query_source"] = source + if owner_id is not None: + where_clauses.append("q.owner_id = :query_owner_id") + params["query_owner_id"] = owner_id + + row = ( + await datasette.get_internal_database().execute( + """ + SELECT count(*) AS count + FROM queries q + JOIN ( + {allowed_sql} + ) allowed + ON allowed.parent = q.database_name + AND allowed.child = q.name + WHERE {where} + """.format( + allowed_sql=allowed_sql, + where=" AND ".join(where_clauses) or "1 = 1", + ), + params, + ) + ).first() + return row["count"] + + +async def list_queries( + datasette: Any, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, +) -> StoredQueryPage: + limit = min(max(1, int(limit)), 1000) + allowed_sql, allowed_params = await datasette.allowed_resources_sql( + action="view-query", + actor=actor, + parent=database, + include_is_private=include_private, + ) + params = dict(allowed_params) + params.update({"limit": limit + 1}) + sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))" + where_clauses = [] + order_by = "q.database_name, sort_key, q.name" + if database is not None: + params["query_database"] = database + where_clauses.append("q.database_name = :query_database") + order_by = "sort_key, q.name" + + if cursor: + try: + components = urlsafe_components(cursor) + except ValueError: + components = [] + if database is None and len(components) == 3: + where_clauses.append(""" + ( + q.database_name > :cursor_database + OR ( + q.database_name = :cursor_database + AND ( + {sort_key_sql} > :cursor_sort_key + OR ( + {sort_key_sql} = :cursor_sort_key + AND q.name > :cursor_name + ) + ) + ) + ) + """.format(sort_key_sql=sort_key_sql)) + params["cursor_database"] = components[0] + params["cursor_sort_key"] = components[1] + params["cursor_name"] = components[2] + elif database is not None and len(components) == 2: + where_clauses.append(""" + ( + {sort_key_sql} > :cursor_sort_key + OR ( + {sort_key_sql} = :cursor_sort_key + AND q.name > :cursor_name + ) + ) + """.format(sort_key_sql=sort_key_sql)) + params["cursor_sort_key"] = components[0] + params["cursor_name"] = components[1] + + if q: + where_clauses.append(""" + ( + q.name LIKE :query_search + OR q.title LIKE :query_search + OR q.description LIKE :query_search + OR q.sql LIKE :query_search + ) + """) + params["query_search"] = "%{}%".format(q) + if is_write is not None: + where_clauses.append("q.is_write = :query_is_write") + params["query_is_write"] = int(bool(is_write)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) + if source is not None: + where_clauses.append("q.source = :query_source") + params["query_source"] = source + if owner_id is not None: + where_clauses.append("q.owner_id = :query_owner_id") + params["query_owner_id"] = owner_id + + private_select = ", allowed.is_private AS private" if include_private else "" + rows = list( + ( + await datasette.get_internal_database().execute( + """ + SELECT q.*, {sort_key_sql} AS sort_key{private_select} + FROM queries q + JOIN ( + {allowed_sql} + ) allowed + ON allowed.parent = q.database_name + AND allowed.child = q.name + WHERE {where} + ORDER BY {order_by} + LIMIT :limit + """.format( + allowed_sql=allowed_sql, + private_select=private_select, + sort_key_sql=sort_key_sql, + where=" AND ".join(where_clauses) or "1 = 1", + order_by=order_by, + ), + params, + ) + ).rows + ) + has_more = len(rows) > limit + if has_more: + rows = rows[:limit] + + queries = [] + for row in rows: + query = query_row_to_stored_query( + row, private=bool(row["private"]) if include_private else None + ) + assert query is not None + queries.append(query) + + next_token = None + if has_more and rows: + last_row = rows[-1] + if database is None: + next_token = "{},{},{}".format( + tilde_encode(last_row["database_name"]), + tilde_encode(last_row["sort_key"]), + tilde_encode(last_row["name"]), + ) + else: + next_token = "{},{}".format( + tilde_encode(last_row["sort_key"]), + tilde_encode(last_row["name"]), + ) + return StoredQueryPage( + queries=queries, + next=next_token, + has_more=has_more, + limit=limit, + ) + + +async def ensure_query_write_permissions( + datasette: Any, + database: str, + sql: str, + *, + actor: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + analysis: Any = None, +) -> Any: + write_actions = { + "insert": "insert-row", + "update": "update-row", + "delete": "delete-row", + } + db = datasette.get_database(database) + 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) + if action is None: + continue + if access.database != database: + raise Forbidden("Writable queries may not write to attached databases") + if not await datasette.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 diff --git a/datasette/templates/_execute_write_analysis_scripts.html b/datasette/templates/_execute_write_analysis_scripts.html new file mode 100644 index 00000000..a19bae13 --- /dev/null +++ b/datasette/templates/_execute_write_analysis_scripts.html @@ -0,0 +1,111 @@ + diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html new file mode 100644 index 00000000..165cfe9f --- /dev/null +++ b/datasette/templates/_execute_write_analysis_styles.html @@ -0,0 +1,41 @@ + diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html new file mode 100644 index 00000000..159a141c --- /dev/null +++ b/datasette/templates/_sql_parameter_scripts.html @@ -0,0 +1,293 @@ + diff --git a/datasette/templates/_sql_parameter_styles.html b/datasette/templates/_sql_parameter_styles.html new file mode 100644 index 00000000..bc6838f5 --- /dev/null +++ b/datasette/templates/_sql_parameter_styles.html @@ -0,0 +1,58 @@ + diff --git a/datasette/templates/_sql_parameters.html b/datasette/templates/_sql_parameters.html new file mode 100644 index 00000000..58801d40 --- /dev/null +++ b/datasette/templates/_sql_parameters.html @@ -0,0 +1,9 @@ +
+ {% if parameter_names %} +

Parameters

+ {% for parameter in parameter_names %} + {% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %} +

{% if sql_parameters_allow_expand|default(false) %} {% endif %}

+ {% endfor %} + {% endif %} +
diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 42b4ca0b..371f6a22 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -5,6 +5,7 @@ {% block extra_head %} {{- super() -}} {% include "_codemirror.html" %} +{% include "_sql_parameter_styles.html" %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} @@ -25,9 +26,13 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} -
+

Custom SQL query

-

+

+ {% set parameter_names = [] %} + {% set parameter_values = {} %} + {% set sql_parameters_allow_expand = false %} + {% include "_sql_parameters.html" %}

@@ -53,6 +58,9 @@

  • {{ query.title or query.name }}{% if query.private %} 🔒{% endif %}
  • {% endfor %} + {% if queries_more %} +

    View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}

    + {% endif %} {% endif %} {% if tables %} @@ -87,5 +95,11 @@ {% endif %} {% include "_codemirror_foot.html" %} +{% include "_sql_parameter_scripts.html" %} + {% endblock %} diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html new file mode 100644 index 00000000..6b626f8d --- /dev/null +++ b/datasette/templates/execute_write.html @@ -0,0 +1,314 @@ +{% extends "base.html" %} + +{% block title %}Write to this database{% endblock %} + +{% block extra_head %} +{{- super() -}} +{% include "_codemirror.html" %} + +{% include "_execute_write_analysis_styles.html" %} +{% include "_sql_parameter_styles.html" %} +{% endblock %} + +{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %} + +{% block crumbs %} +{{ crumbs.nav(request=request, database=database) }} +{% endblock %} + +{% block content %} + +

    Write to this database

    + +

    Execute SQL to insert, update or delete rows in this database.

    + +{% if execution_message %} +

    {{ execution_message }}{% for link in execution_links %} {{ link.label }}{% endfor %}

    +{% endif %} + + + {% if write_template_tables %} +
    +
    + Start with a template +

    + + + + + +

    +
    +
    + {% endif %} + +

    + + {% set sql_parameters_section_id = "execute-write-parameters-section" %} + {% set sql_parameters_allow_expand = true %} + {% include "_sql_parameters.html" %} + +
    +

    Query operations

    + {% if analysis_error %} +

    {{ analysis_error }}

    + {% elif analysis_rows %} +
    + + + + + + + + + + + {% for row in analysis_rows %} + + + + + + + + {% endfor %} + +
    OperationDatabaseTableRequired permissionAllowed
    {{ row.operation }}{{ row.database }}{{ row.table }}{% if row.required_permission %}{{ row.required_permission }}{% endif %}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}
    + {% else %} +

    Analysis will show each affected table and required permission.

    + {% endif %} + + +

    + + {% if save_query_base_url %}Save this query{% endif %} +

    + + + + +{% include "_codemirror_foot.html" %} +{% include "_sql_parameter_scripts.html" %} +{% include "_execute_write_analysis_scripts.html" %} + + + +{% if write_template_tables %} + +{% endif %} + +{% endblock %} diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 5f85ac6b..168a636b 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -14,9 +14,10 @@ {% endif %} {% include "_codemirror.html" %} +{% include "_sql_parameter_styles.html" %} {% endblock %} -{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %} +{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %} {% block crumbs %} {{ crumbs.nav(request=request, database=database) }} @@ -24,19 +25,19 @@ {% block content %} -{% if canned_query_write and db_is_immutable %} +{% if stored_query_write and db_is_immutable %}

    This query cannot be executed because the database is immutable.

    {% endif %} -

    {{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

    +

    {{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}

    {% set action_links, action_title = query_actions(), "Query actions" %} {% include "_action_menu.html" %} -{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} +{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} -
    +

    Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} ({{ show_hide_text }}) {% endif %}

    @@ -45,29 +46,28 @@ {% endif %} {% if not hide_sql %} {% if editable and allow_execute_sql %} -

    {% else %}
    {% if query %}{{ query.sql }}{% endif %}
    {% endif %} {% else %} - {% if not canned_query %} + {% if not stored_query %} {% endif %} {% endif %} - {% if named_parameter_values %} -

    Query parameters

    - {% for name, value in named_parameter_values.items() %} -

    - {% endfor %} - {% endif %} + {% set parameter_names = named_parameter_values.keys()|list %} + {% set parameter_values = named_parameter_values %} + {% set sql_parameters_allow_expand = false %} + {% include "_sql_parameters.html" %}

    {% if not hide_sql %}{% endif %} - + {{ show_hide_hidden }} - {% if canned_query and edit_sql_url %}Edit SQL{% endif %} + {% if save_query_url %}Save this query{% endif %} + {% if stored_query and edit_sql_url %}Edit SQL{% endif %}

    @@ -90,11 +90,17 @@ {% else %} - {% if not canned_query_write and not error %} + {% if not stored_query_write and not error %}

    0 results

    {% endif %} {% endif %} {% include "_codemirror_foot.html" %} +{% include "_sql_parameter_scripts.html" %} + {% endblock %} diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html new file mode 100644 index 00000000..f5dadbff --- /dev/null +++ b/datasette/templates/query_create.html @@ -0,0 +1,302 @@ +{% extends "base.html" %} + +{% block title %}Create query{% endblock %} + +{% block extra_head %} +{{- super() -}} +{% include "_codemirror.html" %} +{% include "_execute_write_analysis_styles.html" %} + +{% endblock %} + +{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %} + +{% block crumbs %} +{{ crumbs.nav(request=request, database=database) }} +{% endblock %} + +{% block content %} + +
    + +

    Create query

    + +
    +
    +

    +

    {{ urls.database(database) }}/

    +

    +
    + +

    + +

    + {% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %} + + + Queries marked private can only be seen by you, their creator. +

    + {% if sql and analysis_is_write %} +

    Execute write SQL

    + {% endif %} + +

    + +
    + {% if has_sql %} +

    Query operations

    + {% if analysis_error %} +

    {{ analysis_error }}

    + {% elif analysis_rows %} +
    + + + + + + + + + + + {% for row in analysis_rows %} + + + + + + + + {% endfor %} + +
    OperationDatabaseTableRequired permissionAllowed
    {{ row.operation }}{{ row.database }}{{ row.table }}{% if row.required_permission %}{{ row.required_permission }}{% else %}n/a{% endif %}{% if row.allowed is none %}n/a{% elif row.allowed %}yes{% else %}no{% endif %}
    + {% else %} +

    Analysis will show each affected table and required permission.

    + {% endif %} + {% endif %} +
    +
    + +
    + +{% include "_codemirror_foot.html" %} +{% include "_sql_parameter_scripts.html" %} +{% include "_execute_write_analysis_scripts.html" %} + + + + + +{% endblock %} diff --git a/datasette/templates/query_list.html b/datasette/templates/query_list.html new file mode 100644 index 00000000..fa4859b1 --- /dev/null +++ b/datasette/templates/query_list.html @@ -0,0 +1,281 @@ +{% extends "base.html" %} + +{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %} + +{% block extra_head %} +{{- super() -}} + +{% endblock %} + +{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %} + +{% block crumbs %} +{{ crumbs.nav(request=request, database=database) }} +{% endblock %} + +{% block content %} + +
    + +

    Queries

    + +
    + +
    + + + +{% if queries %} +
    + + + {% if show_database %}{% endif %} + + + + + + + {% for query in queries %} + + {% if show_database %} + + {% endif %} + + + + + {% endfor %} + +
    DatabaseQueryOwnerFlags
    {{ query.database }} + {{ query.title or query.name }}{% if query.private %} 🔒{% endif %} + {% if query.description %}

    {{ query.description }}

    {% endif %} +
    {% if query.owner_id is not none %}{{ query.owner_id }}{% else %}-{% endif %} + + {% if query.is_write %}Writable{% else %}Read-only{% endif %} + {% if query.is_private %}Private{% endif %} + {% if query.is_trusted %}Trusted{% endif %} + +
    + {% if show_private_note or show_trusted_note %} +
    + {% if show_private_note %}

    PrivateOnly the owning actor can view this query.

    {% endif %} + {% if show_trusted_note %}

    TrustedExecution skips the usual SQL and write permission checks after view-query allows access.

    {% endif %} +
    + {% endif %} +{% else %} +

    No queries found.

    +{% endif %} + +{% if next_url %} + +{% endif %} + +
    + +{% endblock %} diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py index e679ae76..891ee913 100644 --- a/datasette/utils/actions_sql.py +++ b/datasette/utils/actions_sql.py @@ -241,6 +241,14 @@ async def _build_single_action_sql( "),", ] ) + else: + query_parts.extend( + [ + "anon_rules AS (", + " SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0", + "),", + ] + ) # Continue with the cascading logic query_parts.extend( diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index df149928..bf172667 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -112,6 +112,28 @@ async def initialize_metadata_tables(db): config TEXT, PRIMARY KEY (database_name, resource_name, column_name) ); + + CREATE TABLE IF NOT EXISTS queries ( + database_name TEXT NOT NULL, + name TEXT NOT NULL, + sql TEXT NOT NULL, + title TEXT, + description TEXT, + description_html TEXT, + options TEXT NOT NULL DEFAULT '{}', + parameters TEXT NOT NULL DEFAULT '[]', + is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), + source TEXT NOT NULL DEFAULT 'user', + owner_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (database_name, name) + ); + + CREATE INDEX IF NOT EXISTS queries_owner_idx + ON queries(owner_id); """)) diff --git a/datasette/utils/sql_analysis.py b/datasette/utils/sql_analysis.py new file mode 100644 index 00000000..b5317b62 --- /dev/null +++ b/datasette/utils/sql_analysis.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass +from typing import Literal + +from datasette.utils.sqlite import sqlite3 + +SQLTableOperation = Literal["read", "insert", "update", "delete"] + + +@dataclass(frozen=True) +class SQLTableAccess: + operation: SQLTableOperation + database: str | None + table: str + sqlite_schema: str | None + columns: tuple[str, ...] = () + source: str | None = None + + +@dataclass(frozen=True) +class SQLAnalysis: + table_accesses: tuple[SQLTableAccess, ...] + + +_ACTION_TO_OPERATION: dict[int, SQLTableOperation] = { + sqlite3.SQLITE_READ: "read", + sqlite3.SQLITE_INSERT: "insert", + sqlite3.SQLITE_UPDATE: "update", + sqlite3.SQLITE_DELETE: "delete", +} + + +def analyze_sql_tables( + conn, + sql: str, + params=None, + *, + database_name: str | None = None, + schema_to_database: dict[str, str] | None = None, +) -> SQLAnalysis: + """ + Return tables accessed by a SQL statement according to SQLite's authorizer. + + This function is synchronous and connection-based. It temporarily installs a + SQLite authorizer, prepares ``EXPLAIN ``, and returns the table access + callbacks observed while SQLite compiles the statement. + """ + accesses: dict[ + tuple[SQLTableOperation, str | None, str, str | None, str | None], set[str] + ] = {} + + def database_for_schema(sqlite_schema): + if schema_to_database and sqlite_schema in schema_to_database: + return schema_to_database[sqlite_schema] + if sqlite_schema == "main" and database_name is not None: + return database_name + return sqlite_schema + + def authorizer(action, arg1, arg2, sqlite_schema, source): + operation = _ACTION_TO_OPERATION.get(action) + if operation is None or arg1 is None: + return sqlite3.SQLITE_OK + + key = ( + operation, + database_for_schema(sqlite_schema), + arg1, + sqlite_schema, + source, + ) + columns = accesses.setdefault(key, set()) + if operation in ("read", "update") and arg2 is not None: + columns.add(arg2) + return sqlite3.SQLITE_OK + + conn.set_authorizer(authorizer) + try: + conn.execute("EXPLAIN " + sql, params if params is not None else {}).fetchall() + finally: + conn.set_authorizer(None) + + return SQLAnalysis( + table_accesses=tuple( + SQLTableAccess( + operation=operation, + database=database, + table=table, + sqlite_schema=sqlite_schema, + columns=tuple(sorted(columns)), + source=source, + ) + for ( + operation, + database, + table, + sqlite_schema, + source, + ), columns in accesses.items() + ) + ) diff --git a/datasette/views/database.py b/datasette/views/database.py index 8e4ea85a..b558b002 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -13,6 +13,7 @@ import textwrap from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.database import QueryInterrupted from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import stored_query_to_dict from datasette.utils import ( add_cors_headers, await_me_maybe, @@ -35,6 +36,7 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm from .base import BaseView, DatasetteError, View, _error, stream_csv +from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns from . import Context @@ -92,24 +94,19 @@ class DatabaseView(View): tables = await get_tables(datasette, request, db, allowed_dict) - # Get allowed queries using the new permission system - allowed_query_page = await datasette.allowed_resources( - "view-query", - request.actor, - parent=database, - include_is_private=True, - limit=1000, + queries_page = await datasette.list_queries( + database, + actor=request.actor, + limit=5, + include_private=True, + ) + stored_queries = queries_page.queries + queries_more = queries_page.has_more + queries_count = ( + await datasette.count_queries(database, actor=request.actor) + if queries_more + else len(stored_queries) ) - - # Build canned_queries list by looking up each allowed query - all_queries = await datasette.get_canned_queries(database, request.actor) - canned_queries = [] - for query_resource in allowed_query_page.resources: - query_name = query_resource.child - if query_name in all_queries: - canned_queries.append( - dict(all_queries[query_name], private=query_resource.private) - ) async def database_actions(): links = [] @@ -140,7 +137,9 @@ class DatabaseView(View): "tables": tables, "hidden_count": len([t for t in tables if t["hidden"]]), "views": sql_views, - "queries": canned_queries, + "queries": [stored_query_to_dict(query) for query in stored_queries], + "queries_more": queries_more, + "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, "table_columns": ( await _table_columns(datasette, database) if allow_execute_sql else {} @@ -173,7 +172,9 @@ class DatabaseView(View): tables=tables, hidden_count=len([t for t in tables if t["hidden"]]), views=sql_views, - queries=canned_queries, + queries=stored_queries, + queries_more=queries_more, + queries_count=queries_count, allow_execute_sql=allow_execute_sql, table_columns=( await _table_columns(datasette, database) @@ -221,7 +222,11 @@ class DatabaseContext(Context): tables: list = field(metadata={"help": "List of table objects in the database"}) hidden_count: int = field(metadata={"help": "Count of hidden tables"}) views: list = field(metadata={"help": "List of view objects in the database"}) - queries: list = field(metadata={"help": "List of canned query objects"}) + queries: list = field(metadata={"help": "List of stored query objects"}) + queries_more: bool = field( + metadata={"help": "Boolean indicating if more stored queries are available"} + ) + queries_count: int = field(metadata={"help": "Count of visible stored queries"}) allow_execute_sql: bool = field( metadata={"help": "Boolean indicating if custom SQL can be executed"} ) @@ -266,8 +271,8 @@ class QueryContext(Context): query: dict = field( metadata={"help": "The SQL query object containing the `sql` string"} ) - canned_query: str = field( - metadata={"help": "The name of the canned query if this is a canned query"} + stored_query: str = field( + metadata={"help": "The name of the stored query if this is a stored query"} ) private: bool = field( metadata={"help": "Boolean indicating if this is a private database"} @@ -275,13 +280,13 @@ class QueryContext(Context): # urls: dict = field( # metadata={"help": "Object containing URL helpers like `database()`"} # ) - canned_query_write: bool = field( + stored_query_write: bool = field( metadata={ - "help": "Boolean indicating if this is a canned query that allows writes" + "help": "Boolean indicating if this is a stored query that allows writes" } ) metadata: dict = field( - metadata={"help": "Metadata about the database or the canned query"} + metadata={"help": "Metadata about the database or the stored query"} ) db_is_immutable: bool = field( metadata={"help": "Boolean indicating if this database is immutable"} @@ -302,12 +307,15 @@ class QueryContext(Context): allow_execute_sql: bool = field( metadata={"help": "Boolean indicating if custom SQL can be executed"} ) + save_query_url: str = field( + metadata={"help": "URL to save the current arbitrary SQL as a query"} + ) tables: list = field(metadata={"help": "List of table objects in the database"}) named_parameter_values: dict = field( metadata={"help": "Dictionary of parameter names/values"} ) edit_sql_url: str = field( - metadata={"help": "URL to edit the SQL for a canned query"} + metadata={"help": "URL to edit the SQL for a stored query"} ) display_rows: list = field(metadata={"help": "List of result rows to display"}) columns: list = field(metadata={"help": "List of column names"}) @@ -331,8 +339,8 @@ class QueryContext(Context): top_query: callable = field( metadata={"help": "Callable to render the top_query slot"} ) - top_canned_query: callable = field( - metadata={"help": "Callable to render the top_canned_query slot"} + top_stored_query: callable = field( + metadata={"help": "Callable to render the top_stored_query slot"} ) query_actions: callable = field( metadata={ @@ -423,21 +431,32 @@ class QueryView(View): db = await datasette.resolve_database(request) - # We must be a canned query + # We must be a stored query table_found = False try: await datasette.resolve_table(request) table_found = True except TableNotFound as table_not_found: - canned_query = await datasette.get_canned_query( - table_not_found.database_name, table_not_found.table, request.actor + stored_query = await datasette.get_query( + table_not_found.database_name, table_not_found.table ) - if canned_query is None: + if stored_query is None: raise if table_found: # That should not have happened raise DatasetteError("Unexpected table found on POST", status=404) + if not await datasette.allowed( + action="view-query", + resource=QueryResource(database=db.name, query=stored_query.name), + actor=request.actor, + ): + raise Forbidden("You do not have permission to view this query") + + await _ensure_stored_query_execution_permissions( + datasette, db, stored_query, request.actor + ) + # If database is immutable, return an error if not db.is_mutable: raise Forbidden("Database is immutable") @@ -462,20 +481,18 @@ class QueryView(View): or request.args.get("_json") or params.get("_json") ) - params_for_query = MagicParameters( - canned_query["sql"], params, request, datasette - ) + params_for_query = MagicParameters(stored_query.sql, params, request, datasette) await params_for_query.execute_params() ok = None redirect_url = None try: cursor = await db.execute_write( - canned_query["sql"], params_for_query, request=request + stored_query.sql, params_for_query, request=request ) # success message can come from on_success_message or on_success_message_sql message = None message_type = datasette.INFO - on_success_message_sql = canned_query.get("on_success_message_sql") + on_success_message_sql = stored_query.on_success_message_sql if on_success_message_sql: try: message_result = ( @@ -487,18 +504,19 @@ class QueryView(View): message = "Error running on_success_message_sql: {}".format(ex) message_type = datasette.ERROR if not message: - message = canned_query.get( - "on_success_message" - ) or "Query executed, {} row{} affected".format( - cursor.rowcount, "" if cursor.rowcount == 1 else "s" + message = ( + stored_query.on_success_message + or "Query executed, {} row{} affected".format( + cursor.rowcount, "" if cursor.rowcount == 1 else "s" + ) ) - redirect_url = canned_query.get("on_success_redirect") + redirect_url = stored_query.on_success_redirect ok = True except Exception as ex: - message = canned_query.get("on_error_message") or str(ex) + message = stored_query.on_error_message or str(ex) message_type = datasette.ERROR - redirect_url = canned_query.get("on_error_redirect") + redirect_url = stored_query.on_error_redirect ok = False if should_return_json: return Response.json( @@ -531,31 +549,35 @@ class QueryView(View): # Create lookup dict for quick access allowed_dict = {r.child: r for r in allowed_tables_page.resources} - # Are we a canned query? - canned_query = None - canned_query_write = False + # Are we a stored query? + stored_query = None + stored_query_write = False if "table" in request.url_vars: try: await datasette.resolve_table(request) except TableNotFound as table_not_found: - # Was this actually a canned query? - canned_query = await datasette.get_canned_query( - table_not_found.database_name, table_not_found.table, request.actor + # Was this actually a stored query? + stored_query = await datasette.get_query( + table_not_found.database_name, table_not_found.table ) - if canned_query is None: + if stored_query is None: raise - canned_query_write = bool(canned_query.get("write")) + stored_query_write = stored_query.is_write private = False - if canned_query: - # Respect canned query permissions + if stored_query: + # Respect stored query permissions visible, private = await datasette.check_visibility( request.actor, action="view-query", - resource=QueryResource(database=database, query=canned_query["name"]), + resource=QueryResource(database=database, query=stored_query.name), ) if not visible: raise Forbidden("You do not have permission to view this query") + if not stored_query_write: + await _ensure_stored_query_execution_permissions( + datasette, db, stored_query, request.actor + ) else: await datasette.ensure_permission( @@ -568,15 +590,15 @@ class QueryView(View): params = {key: request.args.get(key) for key in request.args} sql = None - if canned_query: - sql = canned_query["sql"] + if stored_query: + sql = stored_query.sql elif "sql" in params: sql = params.pop("sql") # Extract any :named parameters named_parameters = [] - if canned_query and canned_query.get("params"): - named_parameters = canned_query["params"] + if stored_query and stored_query.parameters: + named_parameters = stored_query.parameters if not named_parameters and sql: named_parameters = derive_named_parameters(sql) named_parameter_values = { @@ -602,13 +624,13 @@ class QueryView(View): params_for_query = params - if sql and not canned_query_write: + if sql and not stored_query_write: try: - if not canned_query: + if not stored_query: # For regular queries we only allow SELECT, plus other rules validate_sql_select(sql) else: - # Canned queries can run magic parameters + # Stored queries can run magic parameters params_for_query = MagicParameters(sql, params, request, datasette) await params_for_query.execute_params() results = await datasette.execute( @@ -664,7 +686,7 @@ class QueryView(View): columns=columns, rows=rows, sql=sql, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query.name if stored_query else None, database=database, table=None, request=request, @@ -696,10 +718,10 @@ class QueryView(View): elif format_ == "html": headers = {} templates = [f"query-{to_css_class(database)}.html", "query.html"] - if canned_query: + if stored_query: templates.insert( 0, - f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html", + f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html", ) environment = datasette.get_jinja_environment(request) @@ -717,6 +739,9 @@ class QueryView(View): } ) metadata = await datasette.get_database_metadata(database) + if stored_query: + metadata = stored_query_to_dict(stored_query) + metadata.pop("source", None) renderers = {} for key, (_, can_render) in datasette.renderers.items(): @@ -743,9 +768,14 @@ class QueryView(View): resource=DatabaseResource(database=database), actor=request.actor, ) + allow_store_query = await datasette.allowed( + action="store-query", + resource=DatabaseResource(database=database), + actor=request.actor, + ) show_hide_hidden = "" - if canned_query and canned_query.get("hide_sql"): + if stored_query and stored_query.hide_sql: if bool(params.get("_show_sql")): show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_text = "hide" @@ -793,6 +823,19 @@ class QueryView(View): } ) ) + save_query_url = None + if ( + not stored_query + and allow_execute_sql + and allow_store_query + and is_validated_sql + and ":_" not in sql + ): + save_query_url = ( + datasette.urls.database(database) + + "/-/queries/store?" + + urlencode({"sql": sql}) + ) async def query_actions(): query_actions = [] @@ -800,7 +843,7 @@ class QueryView(View): datasette=datasette, actor=request.actor, database=database, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query.name if stored_query else None, request=request, sql=sql, params=params, @@ -820,16 +863,17 @@ class QueryView(View): "sql": sql, "params": params, }, - canned_query=canned_query["name"] if canned_query else None, + stored_query=stored_query.name if stored_query else None, private=private, - canned_query_write=canned_query_write, + stored_query_write=stored_query_write, db_is_immutable=not db.is_mutable, error=query_error, hide_sql=hide_sql, show_hide_link=datasette.urls.path(show_hide_link), show_hide_text=show_hide_text, - editable=not canned_query, + editable=not stored_query, allow_execute_sql=allow_execute_sql, + save_query_url=save_query_url, tables=await get_tables(datasette, request, db, allowed_dict), named_parameter_values=named_parameter_values, edit_sql_url=edit_sql_url, @@ -849,7 +893,7 @@ class QueryView(View): ) ), show_hide_hidden=markupsafe.Markup(show_hide_hidden), - metadata=canned_query or metadata, + metadata=metadata, alternate_url_json=alternate_url_json, select_templates=[ f"{'*' if template_name == template.name else ''}{template_name}" @@ -858,12 +902,12 @@ class QueryView(View): top_query=make_slot_function( "top_query", datasette, request, database=database, sql=sql ), - top_canned_query=make_slot_function( - "top_canned_query", + top_stored_query=make_slot_function( + "top_stored_query", datasette, request, database=database, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query.name if stored_query else None, ), query_actions=query_actions, ), @@ -1176,22 +1220,6 @@ class TableCreateView(BaseView): return Response.json(details, status=201) -async def _table_columns(datasette, database_name): - internal_db = datasette.get_internal_database() - result = await internal_db.execute( - "select table_name, name from catalog_columns where database_name = ?", - [database_name], - ) - table_columns = {} - for row in result.rows: - table_columns.setdefault(row["table_name"], []).append(row["name"]) - # Add views - db = datasette.get_database(database_name) - for view_name in await db.view_names(): - table_columns[view_name] = [] - return table_columns - - async def display_rows(datasette, database, request, rows, columns): display_rows = [] truncate_cells = datasette.setting("truncate_cells_html") diff --git a/datasette/views/execute_write.py b/datasette/views/execute_write.py new file mode 100644 index 00000000..0054300c --- /dev/null +++ b/datasette/views/execute_write.py @@ -0,0 +1,257 @@ +from urllib.parse import urlencode + +from datasette.resources import DatabaseResource +from datasette.utils import sqlite3 +from datasette.utils.asgi import Response + +from .base import BaseView, _error +from .query_helpers import ( + QueryValidationError, + _analysis_is_write, + _analysis_rows, + _analysis_rows_with_permissions, + _block_framing, + _coerce_execute_write_payload, + _derived_query_parameters, + _execute_write_analysis_data, + _inserted_row_url, + _json_or_form_payload, + _prepare_execute_write, + _table_columns, + _wants_json, +) + + +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_links=None, + execution_ok=None, + status=200, + ): + parameter_values = parameter_values or {} + execution_links = execution_links or [] + parameter_names = [] + analysis_rows = [] + table_columns = await _table_columns(self.ds, db.name) + hidden_table_names = set(await db.hidden_table_names()) + write_template_tables = { + table: columns + for table, columns in table_columns.items() + if columns and table not in hidden_table_names + } + 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)) + + allow_save_query = await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ) and await self.ds.allowed( + action="store-query", + resource=DatabaseResource(db.name), + actor=request.actor, + ) + save_query_base_url = None + save_query_url = None + if allow_save_query: + save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store" + if ( + sql + and analysis_error is None + and not any(row["allowed"] is False for row in analysis_rows) + ): + save_query_url = save_query_base_url + "?" + urlencode({"sql": sql}) + + 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": [ + row for row in analysis_rows if row["operation"] != "read" + ], + "execution_message": execution_message, + "execution_links": execution_links, + "execution_ok": execution_ok, + "execute_disabled": bool( + (not sql) + or analysis_error + or any(row["allowed"] is False for row in analysis_rows) + ), + "table_columns": table_columns, + "write_template_tables": write_template_tables, + "save_query_url": save_query_url, + "save_query_base_url": save_query_base_url, + }, + ) + 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, + ) + if not db.is_mutable: + return _block_framing( + _error( + ["Cannot execute write SQL because this database is immutable."], + 403, + ) + ) + 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), + } + ) + ) + + inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor) + execution_links = ( + [{"href": inserted_row_url, "label": "View row"}] + if inserted_row_url + else [] + ) + 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_links=execution_links, + execution_ok=True, + ) + + +class ExecuteWriteAnalyzeView(BaseView): + name = "execute-write-analyze" + has_json_alternate = False + + async def get(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) + ) + + invalid_keys = set(request.args) - {"sql"} + if invalid_keys: + return _block_framing( + _error( + ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], + 400, + ) + ) + sql = request.args.get("sql") or "" + return _block_framing( + Response.json( + await _execute_write_analysis_data(self.ds, db, sql, request.actor) + ) + ) diff --git a/datasette/views/query_helpers.py b/datasette/views/query_helpers.py new file mode 100644 index 00000000..46d71b8e --- /dev/null +++ b/datasette/views/query_helpers.py @@ -0,0 +1,556 @@ +import json +import re + +from datasette.resources import DatabaseResource, TableResource +from datasette.stored_queries import StoredQuery +from datasette.utils import ( + named_parameters as derive_named_parameters, + escape_sqlite, + path_from_row_pks, + sqlite3, + validate_sql_select, + InvalidSql, +) +from datasette.utils.asgi import Forbidden + +_query_name_re = re.compile(r"^[^/\.\n]+$") + +_query_fields = { + "sql", + "title", + "description", + "hide_sql", + "fragment", + "parameters", + "params", + "is_private", + "on_success_message", + "on_success_redirect", + "on_error_message", + "on_error_redirect", +} + +_query_create_fields = _query_fields | {"name", "mode", "csrftoken"} +_query_update_fields = _query_fields +_query_write_fields = { + "on_success_message", + "on_success_redirect", + "on_error_message", + "on_error_redirect", +} + + +class QueryValidationError(Exception): + def __init__(self, message, status=400): + self.message = message + self.status = status + + +def _actor_id(actor): + if isinstance(actor, dict): + return actor.get("id") + return None + + +def _as_bool(value): + if isinstance(value, bool): + return value + if value is None: + return False + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + return value.lower() in {"1", "true", "t", "yes", "on"} + return bool(value) + + +def _as_optional_bool(value, name): + if value is None or value == "": + return None + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + lowered = value.lower() + if lowered in {"1", "true", "t", "yes", "on"}: + return True + if lowered in {"0", "false", "f", "no", "off"}: + return False + raise QueryValidationError("{} must be 0 or 1".format(name)) + + +def _query_list_limit(value, default=50): + if value in (None, ""): + return default + try: + return min(max(1, int(value)), 1000) + except ValueError as ex: + raise QueryValidationError("_size must be an integer") from ex + + +def _derived_query_parameters(sql): + parameters = [] + seen = set() + for parameter in derive_named_parameters(sql): + if parameter.startswith("_"): + raise QueryValidationError("Magic parameters are not allowed") + if parameter not in seen: + parameters.append(parameter) + seen.add(parameter) + return parameters + + +def _coerce_query_parameters(value, derived): + if value is None: + return derived + if isinstance(value, str): + parameters = [ + parameter.strip() + for parameter in re.split(r"[\s,]+", value) + if parameter.strip() + ] + elif isinstance(value, list): + parameters = value + else: + raise QueryValidationError("parameters must be a list of strings") + if not all(isinstance(parameter, str) for parameter in parameters): + raise QueryValidationError("parameters must be a list of strings") + if any(parameter.startswith("_") for parameter in parameters): + raise QueryValidationError("Magic parameters are not allowed") + if set(parameters) != set(derived): + raise QueryValidationError("parameters must match SQL named parameters") + 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")) + ) + + +def _query_create_form_error_message(message): + return { + "Query name is required": "URL is required", + "Invalid query name": "Invalid URL", + "Query name conflicts with a table or view": ( + "URL conflicts with an existing table or view" + ), + "Query already exists": "A query already exists at that URL", + }.get(message, message) + + +async def _json_or_form_payload(request): + content_type = request.headers.get("content-type", "") + if content_type.startswith("application/json"): + body = await request.post_body() + try: + return json.loads(body or b"{}"), True + except json.JSONDecodeError as e: + raise QueryValidationError("Invalid JSON: {}".format(e)) + return await request.post_vars(), False + + +async def _check_query_name(db, name, *, existing=False): + if not name or not isinstance(name, str): + raise QueryValidationError("Query name is required") + if not _query_name_re.match(name): + raise QueryValidationError("Invalid query name") + if not existing and (await db.table_exists(name) or await db.view_exists(name)): + raise QueryValidationError("Query name conflicts with a table or view") + + +async def _analyze_user_query(datasette, db, sql, *, actor): + if not sql or not isinstance(sql, str): + raise QueryValidationError("SQL is required") + derived = _derived_query_parameters(sql) + params = {parameter: "" for parameter in derived} + try: + analysis = await db.analyze_sql(sql, params) + except sqlite3.DatabaseError as ex: + raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex + + is_write = _analysis_is_write(analysis) + if is_write: + 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 + else: + try: + validate_sql_select(sql) + except InvalidSql as ex: + raise QueryValidationError(str(ex)) from ex + return is_write, derived, analysis + + +def _analysis_rows(analysis): + write_actions = { + "insert": "insert-row", + "update": "update-row", + "delete": "delete-row", + } + return [ + { + "operation": access.operation, + "database": access.database, + "table": access.table, + "required_permission": write_actions.get(access.operation, ""), + "source": access.source, + } + for access in analysis.table_accesses + ] + + +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 + + +async def _ensure_stored_query_execution_permissions( + datasette, db, query: StoredQuery, actor +): + if query.is_trusted: + return + if query.is_write: + await datasette.ensure_permission( + action="execute-write-sql", + resource=DatabaseResource(db.name), + actor=actor, + ) + await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor) + else: + await datasette.ensure_permission( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=actor, + ) + + +async def _execute_write_analysis_data(datasette, db, sql, actor): + parameter_names = [] + analysis_rows = [] + analysis_error = None + if sql: + try: + parameter_names = _derived_query_parameters(sql) + 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( + datasette, analysis, 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)) + return { + "ok": analysis_error is None, + "parameters": parameter_names, + "analysis_error": analysis_error, + "analysis_rows": [row for row in analysis_rows if row["operation"] != "read"], + "execute_disabled": bool( + (not sql) + or analysis_error + or any(row["allowed"] is False for row in analysis_rows) + ), + } + + +async def _query_create_analysis_data(datasette, db, sql, actor): + has_sql = bool(sql and sql.strip()) + parameter_names = [] + analysis_rows = [] + analysis_error = None + if has_sql: + try: + parameter_names = _derived_query_parameters(sql) + params = {parameter: "" for parameter in parameter_names} + analysis = await db.analyze_sql(sql, params) + analysis_rows = await _analysis_rows_with_permissions( + datasette, analysis, actor + ) + except (QueryValidationError, sqlite3.DatabaseError) as ex: + analysis_error = getattr(ex, "message", str(ex)) + return { + "ok": analysis_error is None, + "parameters": parameter_names, + "analysis_error": analysis_error, + "analysis_rows": analysis_rows, + "has_sql": has_sql, + "analysis_is_write": bool( + analysis_rows and any(row["required_permission"] for row in analysis_rows) + ), + "save_disabled": bool( + (not has_sql) + or analysis_error + or any(row["allowed"] is False for row in analysis_rows) + ), + } + + +async def _query_create_form_context( + datasette, + request, + db, + *, + sql="", + name="", + title="", + description="", + is_private=True, +): + analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor) + return { + "database": db.name, + "database_color": db.color, + "sql": sql, + "name": name, + "title": title, + "description": description, + "is_private": is_private, + **analysis_data, + } + + +async def _inserted_row_url(datasette, db, analysis, cursor): + if cursor.rowcount != 1: + return None + lastrowid = getattr(cursor, "lastrowid", None) + if lastrowid is None: + return None + direct_inserts = [ + access + for access in analysis.table_accesses + if access.operation == "insert" + and access.source is None + and access.database == db.name + ] + if len(direct_inserts) != 1: + return None + table = direct_inserts[0].table + pks = await db.primary_keys(table) + use_rowid = not pks + select = ( + "rowid" + if use_rowid + else ", ".join(escape_sqlite(primary_key) for primary_key in pks) + ) + try: + result = await db.execute( + "select {} from {} where rowid = ?".format(select, escape_sqlite(table)), + [lastrowid], + ) + except sqlite3.DatabaseError: + return None + row = result.first() + if row is None: + return None + row_path = path_from_row_pks(row, pks, use_rowid) + return datasette.urls.row(db.name, table, row_path) + + +def _apply_query_data_types(data): + typed = dict(data) + for key in ("hide_sql", "is_private"): + if key in typed: + typed[key] = _as_bool(typed[key]) + return typed + + +async def _prepare_query_create(datasette, request, db, data): + invalid_keys = set(data) - _query_create_fields + if invalid_keys: + raise QueryValidationError( + "Invalid keys: {}".format(", ".join(sorted(invalid_keys))) + ) + + data = _apply_query_data_types(data) + name = data.get("name") + await _check_query_name(db, name) + if await datasette.get_query(db.name, name) is not None: + raise QueryValidationError("Query already exists") + + is_write, derived, analysis = await _analyze_user_query( + datasette, + db, + data.get("sql"), + actor=request.actor, + ) + if not is_write and any(data.get(field) for field in _query_write_fields): + raise QueryValidationError("Writable query fields require writable SQL") + + parameters = _coerce_query_parameters( + data.get("parameters", data.get("params")), + derived, + ) + return { + "name": name, + "sql": data["sql"], + "title": data.get("title"), + "description": data.get("description"), + "hide_sql": _as_bool(data.get("hide_sql")), + "fragment": data.get("fragment"), + "parameters": parameters, + "is_write": is_write, + "is_private": _as_bool(data.get("is_private", True)), + "is_trusted": False, + "source": "user", + "owner_id": _actor_id(request.actor), + "on_success_message": data.get("on_success_message"), + "on_success_redirect": data.get("on_success_redirect"), + "on_error_message": data.get("on_error_message"), + "on_error_redirect": data.get("on_error_redirect"), + "analysis": analysis, + } + + +async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update): + invalid_keys = set(update) - _query_update_fields + if invalid_keys: + raise QueryValidationError( + "Invalid keys: {}".format(", ".join(sorted(invalid_keys))) + ) + + update = _apply_query_data_types(update) + sql = update.get("sql", existing.sql) + query_is_write = existing.is_write + derived = _derived_query_parameters(sql) + parameters = None + + if "sql" in update: + query_is_write, derived, _ = await _analyze_user_query( + datasette, + db, + sql, + actor=request.actor, + ) + + if "parameters" in update or "params" in update: + parameters = _coerce_query_parameters( + update.get("parameters", update.get("params")), + derived, + ) + elif "sql" in update: + parameters = derived + + if not query_is_write and any(update.get(field) for field in _query_write_fields): + raise QueryValidationError("Writable query fields require writable SQL") + + field_values = { + "sql": sql, + "title": update.get("title"), + "description": update.get("description"), + "hide_sql": update.get("hide_sql"), + "fragment": update.get("fragment"), + "parameters": parameters, + "is_write": query_is_write, + "is_private": update.get("is_private"), + "on_success_message": update.get("on_success_message"), + "on_success_redirect": update.get("on_success_redirect"), + "on_error_message": update.get("on_error_message"), + "on_error_redirect": update.get("on_error_redirect"), + } + update_kwargs = {} + for field_name, value in field_values.items(): + if field_name in update: + update_kwargs[field_name] = value + if parameters is not None: + update_kwargs["parameters"] = parameters + if "sql" in update: + update_kwargs["is_write"] = query_is_write + return update_kwargs + + +async def _table_columns(datasette, database_name): + internal_db = datasette.get_internal_database() + result = await internal_db.execute( + "select table_name, name from catalog_columns where database_name = ?", + [database_name], + ) + table_columns = {} + for row in result.rows: + table_columns.setdefault(row["table_name"], []).append(row["name"]) + # Add views + db = datasette.get_database(database_name) + for view_name in await db.view_names(): + table_columns[view_name] = [] + return table_columns diff --git a/datasette/views/stored_queries.py b/datasette/views/stored_queries.py new file mode 100644 index 00000000..8c4e849e --- /dev/null +++ b/datasette/views/stored_queries.py @@ -0,0 +1,483 @@ +from urllib.parse import parse_qsl, urlencode + +from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import stored_query_to_dict +from datasette.utils import sqlite3, tilde_decode +from datasette.utils.asgi import Response + +from .base import BaseView, _error +from .query_helpers import ( + QueryValidationError, + _as_bool, + _as_optional_bool, + _block_framing, + _derived_query_parameters, + _json_or_form_payload, + _prepare_query_create, + _prepare_query_update, + _query_create_analysis_data, + _query_create_form_context, + _query_create_form_error_message, + _query_list_limit, +) + + +class QueryParametersView(BaseView): + name = "query-parameters" + has_json_alternate = False + + async def get(self, request): + db = await self.ds.resolve_database(request) + if not await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need execute-sql"], 403)) + + invalid_keys = set(request.args) - {"sql"} + if invalid_keys: + return _block_framing( + _error( + ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], + 400, + ) + ) + try: + parameters = _derived_query_parameters(request.args.get("sql") or "") + except QueryValidationError as ex: + return _block_framing(_error([ex.message], ex.status)) + return _block_framing(Response.json({"ok": True, "parameters": parameters})) + + +def _query_list_url(path, query_string, *, set_args=None, remove_args=None): + set_args = set_args or {} + remove_args = set(remove_args or ()) + skip = set(set_args) | remove_args | {"_next"} + pairs = [ + (key, value) + for key, value in parse_qsl(query_string, keep_blank_values=True) + if key not in skip + ] + for key, value in set_args.items(): + if value not in (None, ""): + pairs.append((key, value)) + return path + (("?" + urlencode(pairs)) if pairs else "") + + +class QueryListView(BaseView): + name = "query-list" + + async def database_name(self, request): + return (await self.ds.resolve_database(request)).name + + def query_list_path(self, database): + return self.ds.urls.database(database) + "/-/queries" + + async def get(self, request): + database = await self.database_name(request) + format_ = request.url_vars.get("format") or "html" + try: + limit = _query_list_limit( + request.args.get("_size"), + default=20 if format_ == "html" else 50, + ) + is_write = _as_optional_bool(request.args.get("is_write"), "is_write") + is_private = _as_optional_bool(request.args.get("is_private"), "is_private") + except QueryValidationError as ex: + return _error([ex.message], ex.status) + + page = await self.ds.list_queries( + database, + actor=request.actor, + limit=limit, + cursor=request.args.get("_next"), + q=request.args.get("q") or None, + is_write=is_write, + is_private=is_private, + source=request.args.get("source") or None, + owner_id=request.args.get("owner_id") or None, + include_private=True, + ) + query_list_path = self.query_list_path(database) + next_url = None + if page.next: + pairs = [ + (key, value) + for key, value in parse_qsl( + request.query_string, keep_blank_values=True + ) + if key != "_next" + ] + pairs.append(("_next", page.next)) + next_url = "{}?{}".format( + query_list_path, + urlencode(pairs), + ) + + current_filters = { + "actor": request.actor, + "q": request.args.get("q") or None, + "is_write": is_write, + "is_private": is_private, + "source": request.args.get("source") or None, + "owner_id": request.args.get("owner_id") or None, + } + + async def facet_count(field, value): + if current_filters[field] is not None and current_filters[field] != value: + return 0 + filters = dict(current_filters) + filters[field] = value + return await self.ds.count_queries(database, **filters) + + def facet_href(field, value): + if current_filters[field] == value: + return _query_list_url( + query_list_path, + request.query_string, + remove_args=[field], + ) + if current_filters[field] is not None: + return None + return _query_list_url( + query_list_path, + request.query_string, + set_args={field: str(int(value))}, + ) + + async def facet_item(label, field, value): + count = await facet_count(field, value) + active = current_filters[field] == value + if not active and not count: + return None + return { + "label": label, + "count": count, + "href": facet_href(field, value) if active or count else None, + "active": active, + } + + async def facet_items(items): + return [ + item + for item in [ + await facet_item(label, field, value) + for label, field, value in items + ] + if item is not None + ] + + facets = [ + { + "title": "Mode", + "items": await facet_items( + [ + ("Read-only", "is_write", False), + ("Writable", "is_write", True), + ] + ), + }, + { + "title": "Visibility", + "items": await facet_items( + [ + ("Not private", "is_private", False), + ("Private", "is_private", True), + ] + ), + }, + ] + + data = { + "ok": True, + "database": database, + "database_color": ( + self.ds.get_database(database).color if database is not None else None + ), + "queries": page.queries, + "next": page.next, + "next_url": next_url, + "has_more": page.has_more, + "limit": page.limit, + "show_private_note": any(query.is_private for query in page.queries), + "show_trusted_note": any(query.is_trusted for query in page.queries), + "query_list_path": query_list_path, + "show_database": database is None, + "facets": facets, + "filters": { + "q": request.args.get("q") or "", + "is_write": request.args.get("is_write") or "", + "is_private": request.args.get("is_private") or "", + "source": request.args.get("source") or "", + "owner_id": request.args.get("owner_id") or "", + }, + } + if format_ == "json": + return Response.json( + { + **data, + "queries": [stored_query_to_dict(query) for query in page.queries], + } + ) + return await self.render( + ["query_list.html"], + request, + data, + ) + + +class GlobalQueryListView(QueryListView): + name = "global-query-list" + + async def database_name(self, request): + return None + + def query_list_path(self, database): + return self.ds.urls.path("/-/queries") + + +class QueryCreateView(BaseView): + name = "query-create" + has_json_alternate = False + + async def _render_form( + self, + request, + db, + *, + sql="", + name="", + title="", + description="", + is_private=True, + status=200, + ): + response = await self.render( + ["query_create.html"], + request, + await _query_create_form_context( + self.ds, + request, + db, + sql=sql, + name=name, + title=title, + description=description, + is_private=is_private, + ), + ) + response.status = status + return response + + async def get(self, request): + db = await self.ds.resolve_database(request) + await self.ds.ensure_permission( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ) + await self.ds.ensure_permission( + action="store-query", + resource=DatabaseResource(db.name), + actor=request.actor, + ) + + return await self._render_form(request, db, sql=request.args.get("sql") or "") + + +class QueryCreateAnalyzeView(BaseView): + name = "query-create-analyze" + has_json_alternate = False + + async def get(self, request): + db = await self.ds.resolve_database(request) + if not await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need execute-sql"], 403)) + if not await self.ds.allowed( + action="store-query", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need store-query"], 403)) + + invalid_keys = set(request.args) - {"sql"} + if invalid_keys: + return _block_framing( + _error( + ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], + 400, + ) + ) + sql = request.args.get("sql") or "" + return _block_framing( + Response.json( + await _query_create_analysis_data(self.ds, db, sql, request.actor) + ) + ) + + +class QueryStoreView(QueryCreateView): + name = "query-store" + + async def _error_response(self, request, db, query_data, message, status): + message = _query_create_form_error_message(message) + self.ds.add_message(request, message, self.ds.ERROR) + return await self._render_form( + request, + db, + sql=query_data.get("sql") or "", + name=query_data.get("name") or "", + title=query_data.get("title") or "", + description=query_data.get("description") or "", + is_private=_as_bool(query_data.get("is_private", True)), + status=status, + ) + + async def post(self, request): + db = await self.ds.resolve_database(request) + if not await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _error(["Permission denied: need execute-sql"], 403) + if not await self.ds.allowed( + action="store-query", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _error(["Permission denied: need store-query"], 403) + + is_json = False + query_data = {} + try: + data, is_json = await _json_or_form_payload(request) + if not isinstance(data, dict): + raise QueryValidationError("JSON must be a dictionary") + query_data = data.get("query") if is_json else data + if not isinstance(query_data, dict): + raise QueryValidationError("JSON must contain a query dictionary") + prepared = await _prepare_query_create(self.ds, request, db, query_data) + except QueryValidationError as ex: + if not is_json and isinstance(query_data, dict): + return await self._error_response( + request, db, query_data, ex.message, ex.status + ) + return _error([ex.message], ex.status) + + prepared.pop("analysis") + name = prepared.pop("name") + try: + await self.ds.add_query(db.name, name, replace=False, **prepared) + except sqlite3.IntegrityError as ex: + if not is_json and isinstance(query_data, dict): + return await self._error_response(request, db, query_data, str(ex), 400) + return _error([str(ex)], 400) + + query = await self.ds.get_query(db.name, name) + assert query is not None + if is_json: + return Response.json( + {"ok": True, "query": stored_query_to_dict(query)}, status=201 + ) + self.ds.add_message(request, "Query saved", self.ds.INFO) + return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name))) + + +class QueryDefinitionView(BaseView): + name = "query-definition" + + async def get(self, request): + db = await self.ds.resolve_database(request) + query_name = tilde_decode(request.url_vars["query"]) + query = await self.ds.get_query(db.name, query_name) + if query is None: + return _error(["Query not found: {}".format(query_name)], 404) + if not await self.ds.allowed( + action="view-query", + resource=QueryResource(db.name, query_name), + actor=request.actor, + ): + return _error(["Permission denied"], 403) + return Response.json({"ok": True, "query": stored_query_to_dict(query)}) + + +class QueryUpdateView(BaseView): + name = "query-update" + + async def post(self, request): + db = await self.ds.resolve_database(request) + query_name = tilde_decode(request.url_vars["query"]) + existing = await self.ds.get_query(db.name, query_name) + if existing is None: + return _error(["Query not found: {}".format(query_name)], 404) + if not await self.ds.allowed( + action="update-query", + resource=QueryResource(db.name, query_name), + actor=request.actor, + ): + return _error(["Permission denied: need update-query"], 403) + if existing.is_trusted: + return _error(["Trusted queries cannot be updated using the API"], 403) + + try: + data, _ = await _json_or_form_payload(request) + if not isinstance(data, dict): + raise QueryValidationError("JSON must be a dictionary") + invalid_keys = set(data) - {"update", "return"} + if invalid_keys: + raise QueryValidationError( + "Invalid keys: {}".format(", ".join(invalid_keys)) + ) + update = data.get("update") + if not isinstance(update, dict): + raise QueryValidationError("JSON must contain an update dictionary") + if "sql" in update and not await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + raise QueryValidationError( + "Permission denied: need execute-sql", status=403 + ) + update_kwargs = await _prepare_query_update( + self.ds, request, db, existing, update + ) + except QueryValidationError as ex: + return _error([ex.message], ex.status) + + await self.ds.update_query(db.name, query_name, **update_kwargs) + if data.get("return"): + query = await self.ds.get_query(db.name, query_name) + assert query is not None + return Response.json( + { + "ok": True, + "query": stored_query_to_dict(query), + } + ) + return Response.json({"ok": True}) + + +class QueryDeleteView(BaseView): + name = "query-delete" + + async def post(self, request): + db = await self.ds.resolve_database(request) + query_name = tilde_decode(request.url_vars["query"]) + existing = await self.ds.get_query(db.name, query_name) + if existing is None: + return _error(["Query not found: {}".format(query_name)], 404) + if not await self.ds.allowed( + action="delete-query", + resource=QueryResource(db.name, query_name), + actor=request.actor, + ): + return _error(["Permission denied: need delete-query"], 403) + await self.ds.remove_query(db.name, query_name) + return Response.json({"ok": True}) diff --git a/datasette/views/table.py b/datasette/views/table.py index 7027bb10..da69c6b5 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -963,12 +963,12 @@ async def table_view_traced(datasette, request): try: resolved = await datasette.resolve_table(request) except TableNotFound as not_found: - # Was this actually a canned query? - canned_query = await datasette.get_canned_query( - not_found.database_name, not_found.table, request.actor + # Was this actually a stored query? + stored_query = await datasette.get_query( + not_found.database_name, not_found.table ) - # If this is a canned query, not a table, then dispatch to QueryView instead - if canned_query: + # If this is a stored query, not a table, then dispatch to QueryView instead + if stored_query: return await QueryView()(request, datasette) else: raise diff --git a/docs/authentication.rst b/docs/authentication.rst index 7daefab7..f720c12f 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of ` How permissions are resolved ---------------------------- -Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. +Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. ``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified. @@ -468,7 +468,7 @@ You can control the following: * Access to the entire Datasette instance * Access to specific databases * Access to specific tables and views -* Access to specific :ref:`canned_queries` +* Access to specific :ref:`queries ` If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within. @@ -641,12 +641,12 @@ This works for SQL views as well - you can list their names in the ``"tables"`` .. _authentication_permissions_query: -Access to specific canned queries ---------------------------------- +Access to specific queries +-------------------------- -:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. +:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. -To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`: +To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user`: .. [[[cog config_example(cog, """ @@ -1020,7 +1020,7 @@ You can also restrict permissions such that they can only be used within specifi The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database. -Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries ` - within a specific database:: +Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries ` - within a specific database:: datasette create-token root --resource mydatabase mytable insert-row @@ -1285,12 +1285,46 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i view-query ---------- -Actor is allowed to view (and execute) a :ref:`canned query ` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`. +Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted stored query also requires ``execute-sql`` or the relevant write permissions; :ref:`trusted stored queries ` can execute with ``view-query`` alone. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) - - ``query`` is the name of the canned query (string) + + ``query`` is the name of the query (string) + +.. _actions_store_query: + +store-query +----------- + +Actor is allowed to create stored queries against a database. + +``resource`` - ``datasette.resources.DatabaseResource(database)`` + ``database`` is the name of the database (string) + +.. _actions_update_query: + +update-query +------------ + +Actor is allowed to update a stored query. + +``resource`` - ``datasette.resources.QueryResource(database, query)`` + ``database`` is the name of the database (string) + + ``query`` is the name of the query (string) + +.. _actions_delete_query: + +delete-query +------------ + +Actor is allowed to delete a stored query. + +``resource`` - ``datasette.resources.QueryResource(database, query)`` + ``database`` is the name of the database (string) + + ``query`` is the name of the query (string) .. _actions_insert_row: @@ -1379,13 +1413,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 `. +.. _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 diff --git a/docs/changelog.rst b/docs/changelog.rst index dfb2a736..2ba713ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,31 @@ Changelog Unreleased ---------- +Stored queries +~~~~~~~~~~~~~~ + +- The previous "canned queries" feature has been renamed and expanded into :ref:`stored queries `. Queries configured in ``datasette.yaml`` are now loaded into a new ``queries`` table in Datasette's :ref:`internal database `, alongside user-created stored queries. (:issue:`2735`) +- New stored query management APIs: ``datasette.add_query()``, ``datasette.update_query()``, ``datasette.remove_query()``, ``datasette.get_query()``, ``datasette.list_queries()`` and ``datasette.count_queries()``. These replace the removed ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods. (:issue:`2735`) +- Users with :ref:`store-query ` and :ref:`execute-sql ` permission can create stored queries from the SQL query page or the new ``GET //-/queries/store`` form. (:issue:`2735`) +- The database page now shows a count and preview of stored queries, capped at five, and links to new paginated query browsers at ``/-/queries`` and ``//-/queries``. Those browsers support search. (:issue:`2735`) +- Stored queries created by users default to private and untrusted. Private stored queries can only be viewed, updated or deleted by their owner, even if another actor has broad ``view-query``, ``update-query`` or ``delete-query`` permission. Untrusted stored queries execute using the permissions of the actor running them. See :ref:`stored_queries` and :ref:`trusted_stored_queries` for details. (:issue:`2735`) +- New ``store-query``, ``update-query`` and ``delete-query`` permissions, plus updated semantics for :ref:`view-query `. Trusted stored queries can still execute with ``view-query`` alone; untrusted read queries also require :ref:`execute-sql ` and untrusted writable queries require :ref:`execute-write-sql ` plus the relevant table-level write permissions. (:issue:`2735`) + +Write SQL UI +~~~~~~~~~~~~ + +- New "Write to this database" interface at ``//-/execute-write`` for running arbitrary writable SQL against mutable databases. The form extracts named parameters, analyzes the SQL, shows the table operations that will be attempted and links to a newly inserted row when a single-row insert succeeds. (:issue:`2742`) +- Added the new :ref:`execute-write-sql ` permission for running arbitrary writable SQL. Execution is also gated by table-level permissions such as :ref:`insert-row `, :ref:`update-row ` and :ref:`delete-row `, and writes to attached databases are rejected. (:issue:`2742`) + +Plugin API changes +~~~~~~~~~~~~~~~~~~ + +- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) +- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new :ref:`stored query management methods ` together with :ref:`startup() ` to register queries. (:issue:`2735`) + +Bug fixes +~~~~~~~~~ + - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) .. _v1_0_a30: @@ -656,7 +681,7 @@ For more information and workarounds, read `the security advisory `` in a ``