mirror of
https://github.com/simonw/datasette.git
synced 2026-05-27 12:34:37 +02:00
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:
parent
f1dd86ebfb
commit
4a1a4d7807
11 changed files with 421 additions and 218 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue