mirror of
https://github.com/simonw/datasette.git
synced 2026-06-04 08:07:01 +02:00
parent
7e1abd0da4
commit
b4c63966f8
5 changed files with 179 additions and 60 deletions
|
|
@ -572,6 +572,35 @@ class Datasette:
|
|||
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
||||
# a warning to user that they should delete their metadata.json file
|
||||
|
||||
async def apply_queries_config(self):
|
||||
# Apply configured query entries from datasette.yaml to the internal table.
|
||||
await self.get_internal_database().execute_write(
|
||||
"DELETE FROM queries WHERE source = 'config'"
|
||||
)
|
||||
for dbname, db_config in ((self.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 self.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")),
|
||||
published=bool(query_config.get("published")),
|
||||
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 get_jinja_environment(self, request: Request = None) -> Environment:
|
||||
environment = self._jinja_env
|
||||
if request:
|
||||
|
|
@ -732,6 +761,7 @@ class Datasette:
|
|||
await await_me_maybe(hook)
|
||||
# Ensure internal tables and metadata are populated before startup hooks
|
||||
await self._refresh_schemas()
|
||||
await self.apply_queries_config()
|
||||
# Load column_types from config into internal DB
|
||||
await self._apply_column_types_config()
|
||||
for hook in pm.hook.startup(datasette=self):
|
||||
|
|
@ -1439,27 +1469,10 @@ class Datasette:
|
|||
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
|
||||
return await self.get_queries(database_name)
|
||||
|
||||
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
|
||||
return await self.get_query(database_name, query_name)
|
||||
|
||||
def _prepare_connection(self, conn, database):
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from .config import config_permissions_sql as config_permissions_sql
|
|||
from .defaults import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
|
|||
"view-database",
|
||||
"view-database-download",
|
||||
"view-table",
|
||||
"view-query",
|
||||
"execute-sql",
|
||||
}
|
||||
)
|
||||
|
|
@ -67,3 +66,58 @@ 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]:
|
||||
if action != "view-query":
|
||||
return None
|
||||
|
||||
execute_sql = await datasette.allowed_resources_sql(
|
||||
action="execute-sql", actor=actor
|
||||
)
|
||||
sql = execute_sql.sql
|
||||
params = {}
|
||||
for key, value in execute_sql.params.items():
|
||||
new_key = f"query_execute_sql_{key}"
|
||||
sql = sql.replace(f":{key}", f":{new_key}")
|
||||
params[new_key] = value
|
||||
|
||||
trusted_writable_sql = ""
|
||||
if not datasette.default_deny:
|
||||
trusted_writable_sql = """
|
||||
UNION ALL
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'trusted writable query' AS reason
|
||||
FROM queries
|
||||
WHERE is_write = 1
|
||||
AND source IN ('config', 'plugin')
|
||||
"""
|
||||
|
||||
return PermissionSQL(
|
||||
sql=f"""
|
||||
WITH execute_sql_allowed AS (
|
||||
{sql}
|
||||
)
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'published query' AS reason
|
||||
FROM queries
|
||||
WHERE is_write = 0
|
||||
AND published = 1
|
||||
UNION ALL
|
||||
SELECT q.database_name AS parent, q.name AS child, 1 AS allow,
|
||||
'execute-sql allows query' AS reason
|
||||
FROM queries q
|
||||
JOIN execute_sql_allowed es
|
||||
ON es.parent = q.database_name
|
||||
AND es.child IS NULL
|
||||
WHERE q.is_write = 0
|
||||
AND q.published = 0
|
||||
{trusted_writable_sql}
|
||||
""",
|
||||
params=params,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class TableResource(Resource):
|
|||
|
||||
|
||||
class QueryResource(Resource):
|
||||
"""A canned query in a database."""
|
||||
"""A saved 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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -121,3 +122,87 @@ async def test_update_query_only_updates_provided_fields():
|
|||
assert query["on_success_redirect"] is None
|
||||
assert query["sql"] == "select 1"
|
||||
assert query["published"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_queries_imported_to_internal_table():
|
||||
ds = Datasette(
|
||||
memory=True,
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"queries": {
|
||||
"configured": {
|
||||
"sql": "select :name as name",
|
||||
"title": "Configured query",
|
||||
"params": ["name"],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ds.add_memory_database("query_config", name="data")
|
||||
await ds.invoke_startup()
|
||||
|
||||
assert await ds.get_query("data", "configured") == {
|
||||
"database": "data",
|
||||
"name": "configured",
|
||||
"sql": "select :name as name",
|
||||
"title": "Configured query",
|
||||
"description": None,
|
||||
"description_html": None,
|
||||
"hide_sql": False,
|
||||
"fragment": None,
|
||||
"params": ["name"],
|
||||
"parameters": ["name"],
|
||||
"is_write": False,
|
||||
"write": False,
|
||||
"published": False,
|
||||
"source": "config",
|
||||
"owner_id": None,
|
||||
"on_success_message": None,
|
||||
"on_success_message_sql": None,
|
||||
"on_success_redirect": None,
|
||||
"on_error_message": None,
|
||||
"on_error_redirect": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_resources_come_from_internal_table():
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database("query_resources", name="data")
|
||||
await ds.invoke_startup()
|
||||
await ds.add_query("data", "internal_query", "select 1", source="user")
|
||||
|
||||
page = await ds.allowed_resources("view-query", actor=None)
|
||||
|
||||
assert [(r.parent, r.child) for r in page.resources] == [
|
||||
("data", "internal_query")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unpublished_query_requires_execute_sql_but_published_does_not():
|
||||
ds = Datasette(memory=True, settings={"default_allow_sql": False})
|
||||
ds.add_memory_database("query_permissions", name="data")
|
||||
await ds.invoke_startup()
|
||||
await ds.add_query("data", "unpublished", "select 1", published=False)
|
||||
await ds.add_query("data", "published", "select 1", published=True)
|
||||
|
||||
assert not await ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource("data"),
|
||||
actor=None,
|
||||
)
|
||||
assert not await ds.allowed(
|
||||
action="view-query",
|
||||
resource=QueryResource("data", "unpublished"),
|
||||
actor=None,
|
||||
)
|
||||
assert await ds.allowed(
|
||||
action="view-query",
|
||||
resource=QueryResource("data", "published"),
|
||||
actor=None,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue