Query is_trusted and is_private properties

Refs https://github.com/simonw/datasette/issues/2735#issuecomment-4547270516

Diff explanation: https://gist.github.com/simonw/1e4de6c4b041a51968eb273ee96dec1f
This commit is contained in:
Simon Willison 2026-05-26 11:59:49 -07:00
commit 4a1a4d7807
11 changed files with 421 additions and 218 deletions

View file

@ -618,7 +618,8 @@ class Datasette:
fragment=query_config.get("fragment"),
parameters=query_config.get("params"),
is_write=bool(query_config.get("write")),
is_published=bool(query_config.get("is_published")),
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"),
@ -1084,7 +1085,8 @@ class Datasette:
"parameters": parameters,
"is_write": is_write,
"write": is_write,
"is_published": bool(row["is_published"]),
"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"),
@ -1119,7 +1121,8 @@ class Datasette:
fragment=None,
parameters=None,
is_write=False,
is_published=False,
is_private=False,
is_trusted=False,
source="plugin",
owner_id=None,
on_success_message=None,
@ -1144,8 +1147,8 @@ class Datasette:
sql_statement = """
INSERT INTO queries (
database_name, name, sql, title, description, description_html,
options, parameters, is_write, is_published, source, owner_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
options, parameters, is_write, is_private, is_trusted, source, owner_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
if replace:
sql_statement += """
@ -1157,7 +1160,8 @@ class Datasette:
options = excluded.options,
parameters = excluded.parameters,
is_write = excluded.is_write,
is_published = excluded.is_published,
is_private = excluded.is_private,
is_trusted = excluded.is_trusted,
source = excluded.source,
owner_id = excluded.owner_id,
updated_at = CURRENT_TIMESTAMP
@ -1174,7 +1178,8 @@ class Datasette:
options_json,
parameters_json,
int(bool(is_write)),
int(bool(is_published)),
int(bool(is_private)),
int(bool(is_trusted)),
source,
owner_id,
],
@ -1193,7 +1198,8 @@ class Datasette:
fragment=UNCHANGED,
parameters=UNCHANGED,
is_write=UNCHANGED,
is_published=UNCHANGED,
is_private=UNCHANGED,
is_trusted=UNCHANGED,
source=UNCHANGED,
owner_id=UNCHANGED,
on_success_message=UNCHANGED,
@ -1209,7 +1215,8 @@ class Datasette:
"description_html": description_html,
"parameters": parameters,
"is_write": is_write,
"is_published": is_published,
"is_private": is_private,
"is_trusted": is_trusted,
"source": source,
"owner_id": owner_id,
}
@ -1227,7 +1234,7 @@ class Datasette:
for field, value in fields.items():
if value is UNCHANGED:
continue
if field in {"is_write", "is_published"}:
if field in {"is_write", "is_private", "is_trusted"}:
value = int(bool(value))
elif field == "parameters":
value = json.dumps(list(value or []))
@ -1300,7 +1307,8 @@ class Datasette:
cursor=None,
q=None,
is_write=None,
is_published=None,
is_private=None,
is_trusted=None,
source=None,
owner_id=None,
include_private=False,
@ -1372,9 +1380,12 @@ class Datasette:
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_published is not None:
where_clauses.append("q.is_published = :query_is_published")
params["query_is_published"] = int(bool(is_published))
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

View file

@ -68,13 +68,6 @@ def register_actions():
resource_class=DatabaseResource,
also_requires="execute-sql",
),
Action(
name="publish-query",
abbr="pq",
description="Publish saved queries for actors without execute-sql",
resource_class=DatabaseResource,
also_requires="insert-query",
),
# Table-level actions (child-level)
Action(
name="view-table",

View file

@ -26,6 +26,32 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
)
def _configured_query_restriction_selects(datasette: "Datasette") -> tuple[list[str], dict]:
selects = []
params = {}
for index, (database_name, db_config) in enumerate(
((datasette.config or {}).get("databases") or {}).items()
):
for query_name, query_config in (db_config.get("queries") or {}).items():
if isinstance(query_config, dict) and query_config.get("is_private"):
continue
parent_param = f"query_config_parent_{index}_{len(selects)}"
child_param = f"query_config_child_{index}_{len(selects)}"
selects.append(
f"""
SELECT :{parent_param} AS parent, :{child_param} AS child
WHERE NOT EXISTS (
SELECT 1 FROM queries
WHERE database_name = :{parent_param}
AND name = :{child_param}
)
"""
)
params[parent_param] = database_name
params[child_param] = query_name
return selects, params
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(
datasette: "Datasette",
@ -93,61 +119,45 @@ async def default_query_permissions_sql(
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 = ""
params = {"query_owner_id": actor_id}
rule_sqls = []
if not datasette.default_deny:
trusted_writable_sql = """
UNION ALL
rule_sqls.append(
"""
SELECT database_name AS parent, name AS child, 1 AS allow,
'trusted writable query' AS reason
'non-private query' AS reason
FROM queries
WHERE is_write = 1
AND source IN ('config', 'plugin')
"""
WHERE is_private = 0
"""
)
user_writable_sql = ""
if actor_id is not None:
params["query_owner_id"] = actor_id
user_writable_sql = """
UNION ALL
rule_sqls.append(
"""
SELECT database_name AS parent, name AS child, 1 AS allow,
'query owner' AS reason
FROM queries
WHERE is_write = 1
AND source = 'user'
AND owner_id = :query_owner_id
WHERE owner_id = :query_owner_id
"""
)
config_restriction_selects, config_restriction_params = (
_configured_query_restriction_selects(datasette)
)
restriction_sqls = [
"""
SELECT database_name AS parent, name AS child
FROM queries
WHERE is_private = 0
OR owner_id = :query_owner_id
"""
]
restriction_sqls.extend(config_restriction_selects)
params.update(config_restriction_params)
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 is_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.is_published = 0
{trusted_writable_sql}
{user_writable_sql}
""",
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
restriction_sql="\nUNION ALL\n".join(restriction_sqls),
params=params,
)

View file

@ -27,9 +27,7 @@
</p>
<p><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p><label for="query-parameters">Parameters</label> <input id="query-parameters" name="parameters" type="text" value="{{ parameter_names|join(', ') }}"></p>
{% if can_publish %}
<p><label><input type="checkbox" name="is_published" value="1"> Published</label></p>
{% endif %}
<p><label><input type="checkbox" name="is_private" value="1" checked> Private</label></p>
{% if sql and analysis_is_write %}
<p><a href="{{ urls.database(database) }}/-/execute-write?{{ {'sql': sql}|urlencode|safe }}">Execute write SQL</a></p>
{% endif %}

View file

@ -73,7 +73,7 @@
border-collapse: collapse;
font-size: 0.9rem;
margin: 0.25rem 0 1rem;
min-width: 36rem;
min-width: 42rem;
width: 100%;
}
.query-list-results th,
@ -100,6 +100,16 @@
font-size: 0.78rem;
margin: 0.15rem 0 0;
}
.query-list-owner {
color: #39445a;
font-family: var(--font-monospace, monospace);
white-space: nowrap;
}
.query-list-flags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.query-list-pill {
background-color: #eef1f5;
border: 1px solid #d7dde5;
@ -116,15 +126,36 @@
background-color: #fff4db;
border-color: #e2b64e;
}
.query-list-pill-published {
.query-list-pill-public {
background-color: #e7f5ec;
border-color: #9ecfab;
color: #267a3e;
}
.query-list-pill-unpublished {
.query-list-pill-private {
background-color: #f7edf0;
border-color: #dbb8c1;
}
.query-list-pill-trusted {
background-color: #e7f5ec;
border-color: #9ecfab;
color: #267a3e;
}
.query-list-empty {
color: #6b7280;
}
.query-list-footnotes {
border-top: 1px solid #d7dde5;
color: #4f5b6d;
font-size: 0.82rem;
margin: 0.35rem 0 1rem;
padding-top: 0.55rem;
}
.query-list-footnotes p {
margin: 0.25rem 0;
}
.query-list-footnotes .query-list-pill {
margin-right: 0.35rem;
}
.query-list-pagination a {
border: 1px solid #007bff;
border-radius: 0.25rem;
@ -177,10 +208,10 @@
<label><input type="radio" name="is_write" value="1"{% if filters.is_write == "1" %} checked{% endif %}> <span>Writable</span></label>
</fieldset>
<fieldset class="query-list-filter-group">
<legend>Publication</legend>
<label><input type="radio" name="is_published" value=""{% if not filters.is_published %} checked{% endif %}> <span>Any</span></label>
<label><input type="radio" name="is_published" value="1"{% if filters.is_published == "1" %} checked{% endif %}> <span>Published</span></label>
<label><input type="radio" name="is_published" value="0"{% if filters.is_published == "0" %} checked{% endif %}> <span>Unpublished</span></label>
<legend>Visibility</legend>
<label><input type="radio" name="is_private" value=""{% if not filters.is_private %} checked{% endif %}> <span>Any</span></label>
<label><input type="radio" name="is_private" value="0"{% if filters.is_private == "0" %} checked{% endif %}> <span>Normal</span></label>
<label><input type="radio" name="is_private" value="1"{% if filters.is_private == "1" %} checked{% endif %}> <span>Private</span></label>
</fieldset>
</div>
</form>
@ -191,8 +222,8 @@
<tr>
{% if show_database %}<th scope="col">Database</th>{% endif %}
<th scope="col">Query</th>
<th scope="col">Mode</th>
<th scope="col">Publication</th>
<th scope="col">Owner</th>
<th scope="col">Flags</th>
</tr>
</thead>
<tbody>
@ -205,12 +236,24 @@
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
</td>
<td>{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}</td>
<td>{% if query.is_published %}<span class="query-list-pill query-list-pill-published">Published</span>{% else %}<span class="query-list-pill query-list-pill-unpublished">Unpublished</span>{% endif %}</td>
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
<td>
<span class="query-list-flags">
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% if show_private_note or show_trusted_note %}
<div class="query-list-footnotes">
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
</div>
{% endif %}
{% else %}
<p>No queries found.</p>
{% endif %}

View file

@ -123,7 +123,8 @@ async def initialize_metadata_tables(db):
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published 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,

View file

@ -428,7 +428,7 @@ _query_fields = {
"fragment",
"parameters",
"params",
"is_published",
"is_private",
"on_success_message",
"on_success_message_sql",
"on_success_redirect",
@ -571,7 +571,7 @@ async def _check_query_name(db, name, *, existing=False):
raise QueryValidationError("Query name conflicts with a table or view")
async def _analyze_user_query(datasette, db, sql, *, actor, is_published):
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)
@ -583,8 +583,6 @@ async def _analyze_user_query(datasette, db, sql, *, actor, is_published):
is_write = _analysis_is_write(analysis)
if is_write:
if is_published:
raise QueryValidationError("Writable queries cannot be published")
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
@ -680,6 +678,26 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
return parameter_names, params, analysis
async def _ensure_stored_query_execution_permissions(datasette, db, query, actor):
if query.get("is_trusted"):
return
if query.get("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 = []
@ -752,7 +770,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor):
def _apply_query_data_types(data):
typed = dict(data)
for key in ("hide_sql", "is_published"):
for key in ("hide_sql", "is_private"):
if key in typed:
typed[key] = _as_bool(typed[key])
return typed
@ -769,20 +787,12 @@ async def _prepare_query_create(datasette, request, db, data):
if await datasette.get_query(db.name, name) is not None:
raise QueryValidationError("Query already exists")
is_published = _as_bool(data.get("is_published"))
is_write, derived, analysis = await _analyze_user_query(
datasette,
db,
data.get("sql"),
actor=request.actor,
is_published=is_published,
)
if is_published and not await datasette.allowed(
action="publish-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
raise QueryValidationError("Permission denied: need publish-query", status=403)
if not is_write and any(data.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
@ -800,7 +810,8 @@ async def _prepare_query_create(datasette, request, db, data):
"fragment": data.get("fragment"),
"parameters": parameters,
"is_write": is_write,
"is_published": is_published,
"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"),
@ -819,7 +830,6 @@ async def _prepare_query_update(datasette, request, db, existing, update):
update = _apply_query_data_types(update)
sql = update.get("sql", existing["sql"])
is_published = update.get("is_published", existing["is_published"])
query_is_write = existing["is_write"]
derived = _derived_query_parameters(sql)
parameters = None
@ -830,19 +840,7 @@ async def _prepare_query_update(datasette, request, db, existing, update):
db,
sql,
actor=request.actor,
is_published=is_published,
)
elif is_published and query_is_write:
raise QueryValidationError("Writable queries cannot be published")
if is_published and not existing["is_published"]:
if not await datasette.allowed(
action="publish-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
raise QueryValidationError(
"Permission denied: need publish-query", status=403
)
if "parameters" in update or "params" in update:
parameters = _coerce_query_parameters(
@ -864,7 +862,7 @@ async def _prepare_query_update(datasette, request, db, existing, update):
"fragment": update.get("fragment"),
"parameters": parameters,
"is_write": query_is_write,
"is_published": is_published,
"is_private": update.get("is_private"),
"on_success_message": update.get("on_success_message"),
"on_success_message_sql": update.get("on_success_message_sql"),
"on_success_redirect": update.get("on_success_redirect"),
@ -1141,8 +1139,8 @@ class QueryListView(BaseView):
default=20 if format_ == "html" else 50,
)
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_published = _as_optional_bool(
request.args.get("is_published"), "is_published"
is_private = _as_optional_bool(
request.args.get("is_private"), "is_private"
)
except QueryValidationError as ex:
return _error([ex.message], ex.status)
@ -1154,7 +1152,7 @@ class QueryListView(BaseView):
cursor=request.args.get("_next"),
q=request.args.get("q") or None,
is_write=is_write,
is_published=is_published,
is_private=is_private,
source=request.args.get("source") or None,
owner_id=request.args.get("owner_id") or None,
include_private=True,
@ -1186,12 +1184,14 @@ class QueryListView(BaseView):
"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,
"filters": {
"q": request.args.get("q") or "",
"is_write": request.args.get("is_write") or "",
"is_published": request.args.get("is_published") or "",
"is_private": request.args.get("is_private") or "",
"source": request.args.get("source") or "",
"owner_id": request.args.get("owner_id") or "",
},
@ -1255,11 +1255,6 @@ class QueryCreateView(BaseView):
"database_color": db.color,
"sql": sql,
"parameter_names": parameter_names,
"can_publish": await self.ds.allowed(
action="publish-query",
resource=DatabaseResource(db.name),
actor=request.actor,
),
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"analysis_is_write": bool(
@ -1435,9 +1430,9 @@ class QueryView(View):
):
raise Forbidden("You do not have permission to view this query")
if canned_query.get("write") and canned_query.get("source") == "user":
await datasette.ensure_query_write_permissions(
db.name, canned_query["sql"], actor=request.actor
if canned_query.get("write"):
await _ensure_stored_query_execution_permissions(
datasette, db, canned_query, request.actor
)
# If database is immutable, return an error
@ -1558,6 +1553,10 @@ class QueryView(View):
)
if not visible:
raise Forbidden("You do not have permission to view this query")
if not canned_query_write:
await _ensure_stored_query_execution_permissions(
datasette, db, canned_query, request.actor
)
else:
await datasette.ensure_permission(

View file

@ -1299,16 +1299,6 @@ insert-query
Actor is allowed to create saved queries in a database.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_publish_query:
publish-query
-------------
Actor is allowed to publish a saved read-only query so actors without ``execute-sql`` can run it.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)

View file

@ -2158,7 +2158,8 @@ The internal database schema is as follows:
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published 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,

View file

@ -13,9 +13,9 @@ Terminology change: these are now "queries", not "canned queries". Legacy code a
- Internal table name: `queries`.
- Query definitions should use real columns, not a JSON blob for all options.
- Query parameter names live in a `parameters` text column as a JSON array. No default values for parameters in this pass.
- No `queries_database_is_published_idx` index.
- User-created queries require `execute-sql` and `insert-query` on the database. Writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`.
- `publish-query` is the permission for creating or updating a query so users without `execute-sql` can execute it.
- No separate index is needed for the privacy/trust flags yet.
- User-created queries require `execute-sql` and `insert-query` on the database. They default to private, and writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`.
- Configured queries default to trusted, which means actors who can view them can execute them without also holding `execute-sql` or the relevant write permissions. Config can opt out with `is_trusted: false`.
- Add `update-query` and `delete-query`, so administrators can manage queries created by other users.
- Remove the old `canned_queries()` hook from core. If we want compatibility later, build a separate `datasette-old-canned-queries` plugin.
- Writable user-created queries can be supported using `Database.analyze_sql()`, provided we fail closed when analysis cannot prove the required permissions.
@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS queries (
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published 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,
@ -64,11 +65,12 @@ Column notes:
- Less common presentation and writable-query behavior lives in `options`, stored as a JSON object. That covers `hide_sql`, `fragment`, `on_success_message`, `on_success_message_sql`, `on_success_redirect`, `on_error_message`, and `on_error_redirect`.
- `parameters` is a JSON array of parameter names, stored as text. This preserves explicit parameter order, but does not support labels or default values.
- Existing writable query behavior gets `is_write` as a column. Success/error messages, success/error redirects, and `on_success_message_sql` are stored in `options`.
- `is_published` only applies to read-only queries. A writable query can still be public through explicit `view-query` permissions, but the "publish for users without execute-sql" shortcut should be read-only.
- `is_private` means the query is only visible to its owning actor. This is enforced as a permission restriction, so broader `view-query` grants do not expose private rows.
- `is_trusted` means execution skips the usual `execute-sql` or write-permission checks after `view-query` has allowed access.
- `source` distinguishes `user`, `config`, and `plugin` rows.
- `owner_id` is the actor id for user-created rows. It is `NULL` for config/plugin rows.
No separate index is needed on `(database_name, name)` because the primary key already creates one. Do not add a `queries_database_is_published_idx` index for now.
No separate index is needed on `(database_name, name)` because the primary key already creates one.
`QueryResource.resources_sql()` can become:
@ -104,7 +106,6 @@ Remove the old `canned_queries()` hookspec and all core calls to it. If compatib
Add core actions:
- `insert-query`, database-level, for creating queries in a database.
- `publish-query`, database-level, for marking read-only queries as executable by actors who lack `execute-sql`.
- `update-query`, query-level, for modifying existing query definitions.
- `delete-query`, query-level, for deleting existing query definitions.
@ -114,17 +115,11 @@ User-created query creation requires:
- `insert-query` on `DatabaseResource(database)`
- If analysis shows the query is writable, the table-level write permissions described in the writable query section.
Setting `is_published=1` requires:
- `publish-query` on `DatabaseResource(database)`
- The query must be read-only according to `Database.analyze_sql()`.
Updating an existing query requires:
- `update-query` on `QueryResource(database, query)` or default owner permission for a user-owned row.
- If the SQL changes, also require `execute-sql` on the database.
- If the changed SQL is writable, also require the table-level write permissions described in the writable query section.
- If `is_published` changes from `0` to `1`, also require `publish-query` on the database.
Deleting an existing query requires:
@ -133,18 +128,18 @@ Deleting an existing query requires:
Default owner permissions:
- For `source='user' AND owner_id = actor.id`, grant `update-query` and `delete-query`.
- Do not automatically grant execution if the user no longer has the execution permission described below.
- For `source='user' AND owner_id = actor.id`, grant `view-query`. If the query is private, restriction SQL ensures no other actor sees it through a broader grant.
## Executing queries
Default execution rule for read-only queries:
- If `is_published=0`, the actor needs `execute-sql` on the database.
- If `is_published=1`, the actor can execute the query without `execute-sql`.
- If `is_trusted=0`, the actor needs `execute-sql` on the database.
- If `is_trusted=1`, the actor can execute the query without `execute-sql`, provided `view-query` allows access.
Default execution rule for user-created writable queries:
- `is_published` must be `0`.
- `is_trusted` must be `0`.
- The actor must have `view-query`.
- The actor must currently have every write permission required by fresh `Database.analyze_sql()` results for the query SQL.
@ -152,14 +147,14 @@ Implementation:
- Remove `view-query` from the broad `DEFAULT_ALLOW_ACTIONS` set.
- Replace it with query-aware default `view-query` permission SQL.
- For `is_published=1 AND is_write=0`, emit a child-level `view-query` allow.
- For `is_published=0 AND is_write=0`, emit child-level `view-query` allows for queries whose parent database is in the actor's `execute-sql` allowed resources.
- For `is_write=1 AND source='user'`, emit `view-query` only for the owner or actors with explicit `view-query` permission, then have `QueryView` perform the fresh analysis/table-permission check before execution.
- For trusted writable queries, preserve current behavior by emitting child-level `view-query` allows for `is_write=1 AND source IN ('config', 'plugin')` when Datasette is not running with `--default-deny`.
- Emit default `view-query` allows for non-private rows when Datasette is not running with `--default-deny`.
- Emit default `view-query` allows for the owning actor.
- Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist.
- Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`.
For read-only queries this keeps `QueryView` simple: it checks `view-query` for the query resource, and the default permission hook encodes the relationship with `execute-sql`. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis.
For read-only queries this keeps `QueryView` explicit: it checks `view-query` for the query resource, then checks `execute-sql` unless the row is trusted. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis.
Explicit deny rules should still be able to block a published query.
Explicit deny rules should still be able to block a query, and `--default-deny` still blocks trusted queries unless something grants `view-query`.
## Writable queries
@ -180,7 +175,7 @@ Validation flow for user-created queries:
1. Derive named parameters from the SQL and pass harmless placeholder values into `db.analyze_sql()` so SQLite can prepare statements with bindings.
2. If analysis raises a SQLite error, reject the query.
3. If every table access is `read`, treat the query as read-only and require `execute-sql` plus `insert-query`/`update-query` as described above.
4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_published=0`.
4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_trusted=0`.
5. Reject writable user-created queries that access a database other than the database they are being saved against, until `analyze_sql()` can reliably map attached SQLite schemas back to Datasette database names.
6. For every write access returned by analysis, require the corresponding permission on `TableResource(access.database, access.table)`:
- `insert` -> `insert-row`
@ -200,7 +195,7 @@ Fail closed cases for user-created writable queries:
- Analysis reports any write operation that cannot be mapped to a Datasette table resource.
- Analysis reports writes outside the target database.
- The actor lacks any required table write permission.
- `is_published=1` is requested.
- `is_trusted=1` is requested through the user-facing API.
This gives us writable user-created queries without letting `execute-sql` alone become a path to create arbitrary write endpoints.
@ -225,7 +220,7 @@ Create request:
"sql": "select * from customers order by revenue desc limit 20",
"title": "Top customers",
"description": "Highest revenue customers",
"is_published": false,
"is_private": true,
"parameters": ["region"]
}
}
@ -242,7 +237,8 @@ Successful create returns `201` and the created query definition:
"sql": "select * from customers order by revenue desc limit 20",
"title": "Top customers",
"description": "Highest revenue customers",
"is_published": false,
"is_private": true,
"is_trusted": false,
"parameters": ["region"]
}
}
@ -254,7 +250,7 @@ Update request, imitating `RowUpdateView`:
{
"update": {
"title": "Top customers by revenue",
"is_published": true
"is_private": false
},
"return": true
}
@ -270,7 +266,8 @@ Successful update returns `{"ok": true}` by default. With `"return": true`, retu
"name": "top_customers",
"sql": "select * from customers order by revenue desc limit 20",
"title": "Top customers by revenue",
"is_published": true
"is_private": false,
"is_trusted": false
}
}
```
@ -317,7 +314,8 @@ await datasette.add_query(
fragment=None,
parameters=None,
is_write=False,
is_published=False,
is_private=False,
is_trusted=False,
source="plugin",
owner_id=None,
on_success_message=None,
@ -340,7 +338,8 @@ await datasette.update_query(
fragment=UNCHANGED,
parameters=UNCHANGED,
is_write=UNCHANGED,
is_published=UNCHANGED,
is_private=UNCHANGED,
is_trusted=UNCHANGED,
source=UNCHANGED,
owner_id=UNCHANGED,
on_success_message=UNCHANGED,
@ -360,7 +359,8 @@ await datasette.list_queries(
cursor=None,
q=None,
is_write=None,
is_published=None,
is_private=None,
is_trusted=None,
source=None,
owner_id=None,
)
@ -382,15 +382,13 @@ For column-backed fields, `None` should write SQL `NULL`. For option fields, `No
Implementation detail: build the `UPDATE` statement dynamically from fields whose value is not `UNCHANGED`, validate non-nullable fields before writing, and update `updated_at` whenever at least one field changes.
The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_published`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`.
The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_private`, `is_trusted`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`.
## Query page save UI
On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation.
The save form should call `POST /{database}/-/queries/insert` and default to `is_published=false`.
If the actor also has `publish-query`, include a publish control. The UI copy should make it clear that publishing allows people without arbitrary SQL permission to run this query.
The save form should call `POST /{database}/-/queries/insert` and default to `is_private=true`.
On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination. The global `/-/queries` page reuses the same interface and shows the database for each query.
@ -403,7 +401,7 @@ This page should require `execute-sql` and `insert-query` to access. It should p
- Read-only
- Writable
Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and optional published status if the actor has `publish-query`.
Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and privacy status.
Writable mode should always run `Database.analyze_sql()` and show an analysis panel before saving:
@ -413,7 +411,7 @@ Writable mode should always run `Database.analyze_sql()` and show an analysis pa
- whether the actor has that permission
- source, when the operation comes from a trigger or view
The Save button should be disabled until analysis succeeds and every required table write permission is allowed. Writable mode should not show a publish control, because user-created writable queries cannot be published.
The Save button should be disabled until analysis succeeds and every required table write permission is allowed.
The existing edit-SQL flow from query pages can continue to point back to arbitrary SQL. A later enhancement can add "update this query" when the actor owns it or has `update-query`.
@ -427,14 +425,16 @@ The existing edit-SQL flow from query pages can continue to point back to arbitr
- `QueryResource.resources_sql()` returns rows from `queries`.
- Database page and `/-/jump` list queries from the internal DB.
- `view-query` is no longer globally default-allowed; default query permissions come from the query-aware hook.
- Unpublished read-only query requires `execute-sql` to execute.
- Published read-only query can be executed without `execute-sql`.
- Setting `is_published=true` requires `publish-query`.
- Private query is only visible to its owner, even when a broader `view-query` rule applies.
- Non-trusted read-only query requires `execute-sql` to execute.
- Trusted read-only query can be executed without `execute-sql` after `view-query` passes.
- Config queries default to trusted and can opt out with `is_trusted: false`.
- User API rejects client-supplied `is_trusted`.
- User-created query requires both `execute-sql` and `insert-query`.
- User-created writable query creation uses `Database.analyze_sql()` and requires matching `insert-row`, `update-row`, and/or `delete-row` permissions for every reported write access.
- `/{database}/-/queries/-/create` provides the writable-query authoring UI with an analysis panel and disabled save until all required write permissions pass.
- User-created writable query execution re-runs `Database.analyze_sql()` and re-checks table write permissions.
- User-created writable query cannot be published.
- User-created writable query cannot be trusted through the user API.
- Query update uses `POST /{database}/{query}/-/update` with an `{"update": {...}}` body.
- Query delete uses `POST /{database}/{query}/-/delete`.
- There are no `PATCH` or HTTP `DELETE` routes for query management.

View file

@ -15,7 +15,6 @@ async def add_numbered_queries(ds, database, count):
"select {} as query_number".format(i),
title="Demo query {:02d}".format(i),
description="Seeded demo query number {:02d}".format(i),
is_published=True,
source="user",
owner_id="root",
)
@ -44,7 +43,8 @@ async def test_queries_internal_table_schema():
"options",
"parameters",
"is_write",
"is_published",
"is_private",
"is_trusted",
"source",
"owner_id",
"created_at",
@ -67,7 +67,7 @@ async def test_add_get_and_remove_query():
hide_sql=True,
fragment="chart",
parameters=["region"],
is_published=True,
is_trusted=True,
source="user",
owner_id="alice",
)
@ -100,7 +100,8 @@ async def test_add_get_and_remove_query():
"parameters": ["region"],
"is_write": False,
"write": False,
"is_published": True,
"is_private": False,
"is_trusted": True,
"source": "user",
"owner_id": "alice",
"on_success_message": None,
@ -161,7 +162,8 @@ async def test_update_query_only_updates_provided_fields():
assert query["params"] == []
assert query["on_success_redirect"] is None
assert query["sql"] == "select 1"
assert query["is_published"] is False
assert query["is_private"] is False
assert query["is_trusted"] is False
options_row = (
await ds.get_internal_database().execute(
"""
@ -208,7 +210,8 @@ async def test_config_queries_imported_to_internal_table():
"parameters": ["name"],
"is_write": False,
"write": False,
"is_published": False,
"is_private": False,
"is_trusted": True,
"source": "config",
"owner_id": None,
"on_success_message": None,
@ -232,30 +235,171 @@ async def test_query_resources_come_from_internal_table():
@pytest.mark.asyncio
async def test_unpublished_query_requires_execute_sql_but_published_does_not():
ds = Datasette(memory=True, settings={"default_allow_sql": False})
async def test_default_deny_blocks_view_query_even_for_trusted_query():
ds = Datasette(memory=True, default_deny=True)
ds.add_memory_database("query_permissions", name="data")
await ds.invoke_startup()
await ds.add_query("data", "unpublished", "select 1", is_published=False)
await ds.add_query("data", "published", "select 1", is_published=True)
await ds.add_query("data", "trusted", "select 1", is_trusted=True)
assert not await ds.allowed(
action="execute-sql",
resource=DatabaseResource("data"),
action="view-query",
resource=QueryResource("data", "trusted"),
actor=None,
)
@pytest.mark.asyncio
async def test_private_query_restriction_blocks_broad_view_query_permission():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-query": {"id": "*"},
}
}
}
},
)
ds.add_memory_database("private_query_permissions", name="data")
await ds.invoke_startup()
await ds.add_query(
"data",
"private_report",
"select 1",
is_private=True,
source="user",
owner_id="alice",
)
await ds.add_query(
"data",
"shared_report",
"select 2",
is_private=False,
source="user",
owner_id="alice",
)
assert await ds.allowed(
action="view-query",
resource=QueryResource("data", "private_report"),
actor={"id": "alice"},
)
assert not await ds.allowed(
action="view-query",
resource=QueryResource("data", "unpublished"),
actor=None,
resource=QueryResource("data", "private_report"),
actor={"id": "bob"},
)
assert await ds.allowed(
action="view-query",
resource=QueryResource("data", "published"),
actor=None,
resource=QueryResource("data", "shared_report"),
actor={"id": "bob"},
)
@pytest.mark.asyncio
async def test_config_query_restriction_does_not_override_private_internal_query():
ds = Datasette(memory=True, default_deny=True)
ds.add_memory_database("private_query_with_config_name", name="data")
await ds.invoke_startup()
await ds.add_query(
"data",
"private_report",
"select 1",
is_private=True,
source="user",
owner_id="alice",
)
ds.config = {
"databases": {
"data": {
"permissions": {"view-query": {"id": "*"}},
"queries": {"private_report": {"sql": "select 2"}},
}
}
}
assert not await ds.allowed(
action="view-query",
resource=QueryResource("data", "private_report"),
actor={"id": "bob"},
)
@pytest.mark.asyncio
async def test_untrusted_shared_query_execution_requires_execute_sql():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-database": {"id": "viewer"},
"view-query": {"id": "viewer"},
}
}
}
},
)
ds.add_memory_database("untrusted_query_execution", name="data")
await ds.invoke_startup()
await ds.add_query(
"data",
"shared_report",
"select 1 as one",
is_private=False,
is_trusted=False,
source="user",
owner_id="alice",
)
denied = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"})
assert denied.status_code == 403
ds.config["databases"]["data"]["permissions"]["execute-sql"] = {"id": "viewer"}
allowed = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"})
assert allowed.status_code == 200
assert allowed.json()["rows"] == [{"one": 1}]
@pytest.mark.asyncio
async def test_config_queries_are_trusted_by_default_but_can_opt_out():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-query": {"id": "viewer"},
},
"queries": {
"trusted_report": {"sql": "select 1 as one"},
"untrusted_report": {
"sql": "select 2 as two",
"is_trusted": False,
},
},
}
}
},
)
ds.add_memory_database("trusted_query_config", name="data")
await ds.invoke_startup()
trusted = await ds.client.get("/data/trusted_report.json", actor={"id": "viewer"})
untrusted = await ds.client.get(
"/data/untrusted_report.json", actor={"id": "viewer"}
)
assert trusted.status_code == 200
assert trusted.json()["rows"] == [{"one": 1}]
assert untrusted.status_code == 403
@pytest.mark.asyncio
async def test_database_page_query_preview_is_limited():
ds = Datasette(memory=True)
@ -281,7 +425,6 @@ async def test_query_actions_are_registered():
assert ds.get_action("execute-write-sql").resource_class is DatabaseResource
assert ds.get_action("insert-query").resource_class is DatabaseResource
assert ds.get_action("publish-query").resource_class is DatabaseResource
assert ds.get_action("update-query").resource_class is QueryResource
assert ds.get_action("delete-query").resource_class is QueryResource
@ -430,21 +573,33 @@ async def test_query_list_search_filter_and_html():
"private_query",
"select 'private'",
title="Private query",
is_published=False,
is_private=True,
source="user",
owner_id="root",
)
await ds.add_query(
"data",
"trusted_query",
"select 'trusted'",
title="Trusted query",
is_trusted=True,
source="config",
)
html_response = await ds.client.get(
"/data/-/queries?q=02",
actor={"id": "root"},
)
flags_response = await ds.client.get(
"/data/-/queries",
actor={"id": "root"},
)
json_response = await ds.client.get(
"/data/-/queries.json?q=02",
actor={"id": "root"},
)
filtered_response = await ds.client.get(
"/data/-/queries.json?is_published=0",
"/data/-/queries.json?is_private=1",
actor={"id": "root"},
)
@ -453,7 +608,22 @@ async def test_query_list_search_filter_and_html():
assert "Demo query 01" not in html_response.text
assert 'class="query-list-results"' in html_response.text
assert "<legend>Mode</legend>" in html_response.text
assert 'type="radio" name="is_published" value="1"' in html_response.text
assert 'type="radio" name="is_private" value="1"' in html_response.text
assert "Only the owning actor can view this query." not in html_response.text
assert (
"Execution skips the usual SQL and write permission checks"
not in html_response.text
)
assert flags_response.status_code == 200
assert '<th scope="col">Owner</th>' in flags_response.text
assert '<th scope="col">Flags</th>' in flags_response.text
assert '<th scope="col">Mode</th>' not in flags_response.text
assert 'class="query-list-owner">root</td>' in flags_response.text
assert 'class="query-list-pill">Read-only</span>' in flags_response.text
assert 'class="query-list-pill query-list-pill-private">Private</span>' in flags_response.text
assert 'class="query-list-pill query-list-pill-trusted">Trusted</span>' in flags_response.text
assert "Only the owning actor can view this query." in flags_response.text
assert "Execution skips the usual SQL and write permission checks" in flags_response.text
assert json_response.json()["queries"][0]["name"] == "demo_query_02"
assert [query["name"] for query in filtered_response.json()["queries"]] == [
"private_query"
@ -491,7 +661,6 @@ async def test_global_query_list_api_and_html():
"alpha_first",
"select 1",
title="Alpha first",
is_published=True,
source="user",
owner_id="root",
)
@ -500,7 +669,6 @@ async def test_global_query_list_api_and_html():
"alpha_second",
"select 2",
title="Alpha second",
is_published=True,
source="user",
owner_id="root",
)
@ -509,7 +677,6 @@ async def test_global_query_list_api_and_html():
"beta_first",
"select 3",
title="Beta first",
is_published=True,
source="user",
owner_id="root",
)
@ -548,7 +715,7 @@ async def test_global_query_list_api_and_html():
@pytest.mark.asyncio
async def test_query_insert_api_publish_requires_publish_query():
async def test_query_insert_api_rejects_is_trusted():
ds = Datasette(
memory=True,
default_deny=True,
@ -564,17 +731,17 @@ async def test_query_insert_api_publish_requires_publish_query():
}
},
)
ds.add_memory_database("query_publish_api", name="data")
ds.add_memory_database("query_trusted_api", name="data")
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/queries/insert",
actor={"id": "writer"},
json={"query": {"name": "public", "sql": "select 1", "is_published": True}},
json={"query": {"name": "trusted", "sql": "select 1", "is_trusted": True}},
)
assert response.status_code == 403
assert response.json()["errors"] == ["Permission denied: need publish-query"]
assert response.status_code == 400
assert response.json()["errors"] == ["Invalid keys: is_trusted"]
@pytest.mark.asyncio
@ -599,24 +766,10 @@ async def test_query_insert_api_creates_writable_query():
assert response.status_code == 201
query = response.json()["query"]
assert query["is_write"] is True
assert query["is_published"] is False
assert query["is_private"] is True
assert query["is_trusted"] is False
assert query["parameters"] == ["name"]
bad_response = await ds.client.post(
"/data/-/queries/insert",
actor={"id": "root"},
json={
"query": {
"name": "published_insert",
"sql": "insert into dogs (name) values (:name)",
"is_published": True,
}
},
)
assert bad_response.status_code == 400
assert bad_response.json()["errors"] == ["Writable queries cannot be published"]
@pytest.mark.asyncio
async def test_query_update_and_delete_api():
@ -1103,6 +1256,10 @@ async def test_user_writable_query_execution_rechecks_table_permissions():
config={
"databases": {
"data": {
"permissions": {
"view-database": {"id": ["alice", "bob"]},
"execute-write-sql": {"id": ["alice", "bob"]},
},
"tables": {
"dogs": {
"permissions": {