diff --git a/datasette/app.py b/datasette/app.py index 0e31273d..4ba5d20f 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -753,7 +753,19 @@ class Datasette: # Compare schema versions to see if we should skip it if schema_version == current_schema_versions.get(database_name): continue - await populate_schema_tables(internal_db, db, schema_version) + placeholders = "(?, ?, ?, ?)" + values = [database_name, str(db.path), db.is_memory, schema_version] + if db.path is None: + placeholders = "(?, null, ?, ?)" + values = [database_name, db.is_memory, schema_version] + await internal_db.execute_write( + """ + INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version) + VALUES {} + """.format(placeholders), + values, + ) + await populate_schema_tables(internal_db, db) @property def urls(self): @@ -2563,7 +2575,7 @@ class Datasette: JsonDataView.as_view( self, "plugins.json", - self._plugins, + lambda request: {"plugins": self._plugins(request)}, needs_request=True, ), r"/-/plugins(\.(?Pjson))?$", diff --git a/datasette/database.py b/datasette/database.py index bab3a378..e7fe1ed9 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -17,7 +17,6 @@ from .utils import ( detect_fts, detect_primary_keys, detect_spatialite, - escape_sqlite, get_all_foreign_keys, get_outbound_foreign_keys, md5_not_usedforsecurity, @@ -247,7 +246,6 @@ class Database: request=None, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT, - transaction=True, ): self._check_not_closed() if returning_limit < 0: @@ -260,9 +258,7 @@ class Database: ) with trace("sql", database=self.name, sql=sql.strip(), params=params): - results = await self.execute_write_fn( - _inner, block=block, request=request, transaction=transaction - ) + results = await self.execute_write_fn(_inner, block=block, request=request) return results async def execute_write_script(self, sql, block=True, request=None): @@ -352,7 +348,6 @@ class Database: self.ds._prepare_connection(self._write_connection, self.name) if transaction: with self._write_connection: - self._write_connection.execute("BEGIN IMMEDIATE") result = fn(self._write_connection) else: result = fn(self._write_connection) @@ -482,7 +477,6 @@ class Database: try: if task.transaction: with conn: - conn.execute("BEGIN IMMEDIATE") result = task.fn(conn) else: result = task.fn(conn) @@ -609,7 +603,7 @@ class Database: try: table_count = ( await self.execute( - f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})", + f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})", custom_time_limit=limit, ) ).rows[0][0] diff --git a/datasette/default_permissions/config.py b/datasette/default_permissions/config.py index 8edc976e..aab87c1c 100644 --- a/datasette/default_permissions/config.py +++ b/datasette/default_permissions/config.py @@ -96,10 +96,6 @@ class ConfigPermissionProcessor: """Evaluate an allow block against the current actor.""" if allow_block is None: return None - # Values passed using ``-s permissions.* 1`` or ``0`` are parsed as - # integers, but should retain the CLI's boolean 1/0 behavior. - if isinstance(allow_block, int) and allow_block in (0, 1): - return bool(allow_block) return actor_matches_allow(self.actor, allow_block) def is_in_restriction_allowlist( diff --git a/datasette/facets.py b/datasette/facets.py index 5f757df3..abe0605e 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -85,7 +85,7 @@ class Facet: self.database = database # For foreign key expansion. Can be None for e.g. stored SQL queries: self.table = table - self.sql = sql or f"select * from {escape_sqlite(table)}" + self.sql = sql or f"select * from [{table}]" self.params = params or [] self.table_config = table_config # row_count can be None, in which case we calculate it ourselves: diff --git a/datasette/templates/_permission_ui_styles.html b/datasette/templates/_permission_ui_styles.html index 21a2ea8f..53a824f1 100644 --- a/datasette/templates/_permission_ui_styles.html +++ b/datasette/templates/_permission_ui_styles.html @@ -6,20 +6,8 @@ padding: 1.5em; margin-bottom: 2em; } -.permission-form form { - max-width: 60rem; -} -.permission-form-grid { - display: grid; - gap: 1.5rem; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} -.permission-form-result { - margin-top: 1rem; - max-width: 60rem; -} .form-section { - margin-bottom: 1.25em; + margin-bottom: 1em; } .form-section label { display: block; @@ -27,51 +15,22 @@ font-weight: bold; } .form-section input[type="text"], -.form-section input[type="number"], -.form-section select, -.permission-textarea { - background-color: #fff; - border: 1px solid #aaa; - border-radius: 4px; - box-sizing: border-box; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08); - color: #222; - font-family: inherit; - font-size: 1rem; - line-height: 1.4; - max-width: none; - width: 100%; -} -.form-section input[type="text"] { - height: 3rem; - padding: 0.6rem 0.75rem; -} -.form-section input[type="number"] { - height: 3rem; - max-width: 7rem; - padding: 0.6rem 0.75rem; -} .form-section select { - height: 3rem; - padding: 0.6rem 0.75rem; -} -.permission-textarea { - font-family: monospace; - min-height: 12rem; - padding: 0.75rem; - resize: vertical; + width: 100%; + max-width: 500px; + padding: 0.5em; + box-sizing: border-box; + border: 1px solid #ccc; + border-radius: 3px; } .form-section input[type="text"]:focus, -.form-section input[type="number"]:focus, -.form-section select:focus, -.permission-textarea:focus { +.form-section select:focus { + outline: 2px solid #0066cc; border-color: #0066cc; - box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18); - outline: none; } .form-section small { display: block; - margin-top: 0.45em; + margin-top: 0.3em; color: #666; } .form-actions { @@ -183,9 +142,4 @@ text-align: center; color: #666; } -@media only screen and (max-width: 576px) { - .permission-form-grid { - grid-template-columns: minmax(0, 1fr); - } -} diff --git a/datasette/templates/_permissions_debug_tabs.html b/datasette/templates/_permissions_debug_tabs.html index 8e0f486e..d7203c1e 100644 --- a/datasette/templates/_permissions_debug_tabs.html +++ b/datasette/templates/_permissions_debug_tabs.html @@ -44,10 +44,10 @@ diff --git a/datasette/templates/allow_debug.html b/datasette/templates/allow_debug.html index fda4032c..1ecc92df 100644 --- a/datasette/templates/allow_debug.html +++ b/datasette/templates/allow_debug.html @@ -3,11 +3,29 @@ {% block title %}Debug allow rules{% endblock %} {% block extra_head %} -{% include "_permission_ui_styles.html" %} {% endblock %} @@ -20,28 +38,24 @@ p.message-warning {

Use this tool to try out different actor and allow combinations. See Defining permissions with "allow" blocks for documentation.

-
-
-
-
- - -
-
- - -
-
-
- -
-
+
+
+

+ +
+
+

+ +
+
+ +
+
- {% if error %}

{{ error }}

{% endif %} +{% if error %}

{{ error }}

{% endif %} - {% if result == "True" %}

Result: allow

{% endif %} +{% if result == "True" %}

Result: allow

{% endif %} - {% if result == "False" %}

Result: deny

{% endif %} -
+{% if result == "False" %}

Result: deny

{% endif %} {% endblock %} diff --git a/datasette/templates/debug_allowed.html b/datasette/templates/debug_allowed.html index 80249d9c..83cc1ae6 100644 --- a/datasette/templates/debug_allowed.html +++ b/datasette/templates/debug_allowed.html @@ -49,7 +49,7 @@
- + Number of results per page (max 200)
diff --git a/datasette/templates/debug_check.html b/datasette/templates/debug_check.html index b9fc636a..3b229a25 100644 --- a/datasette/templates/debug_check.html +++ b/datasette/templates/debug_check.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Explain a permission decision{% endblock %} +{% block title %}Permission Check{% endblock %} {% block extra_head %} @@ -13,35 +13,29 @@ border-radius: 5px; } #output.allowed { - background-color: #f3fbf4; + background-color: #e8f5e9; border: 2px solid #4caf50; } #output.denied { - background-color: #fff7f7; + background-color: #ffebee; border: 2px solid #f44336; } #output h2 { margin-top: 0; } -#output h3 { - margin-bottom: 0.5em; -} -#output .result-badge, -.effect-badge, -.rule-status { +#output .result-badge { display: inline-block; - padding: 0.2em 0.5em; + padding: 0.3em 0.8em; border-radius: 3px; font-weight: bold; + font-size: 1.1em; } -#output .allowed-badge, -.effect-allow { - background-color: #2e7d32; +#output .allowed-badge { + background-color: #4caf50; color: white; } -#output .denied-badge, -.effect-deny { - background-color: #c62828; +#output .denied-badge { + background-color: #f44336; color: white; } .details-section { @@ -54,130 +48,70 @@ .details-section dd { margin-left: 1em; } -.explanation-section { - background: rgba(255, 255, 255, 0.75); - border: 1px solid #ddd; - border-radius: 4px; - margin-top: 1em; - padding: 0 1em 1em; -} -.rules-table { - border-collapse: collapse; - width: 100%; -} -.rules-table th, -.rules-table td { - border-bottom: 1px solid #ddd; - padding: 0.5em; - text-align: left; - vertical-align: top; -} -.rule-status { - background: #e8f5e9; - color: #1b5e20; -} -.rule-ignored { - background: #eee; - color: #555; - font-weight: normal; -} -.requirement-allowed { - color: #1b5e20; -} -.requirement-denied { - color: #b71c1c; -} -@media only screen and (max-width: 576px) { - .rules-table, - .rules-table tbody, - .rules-table tr, - .rules-table td { - display: block; - } - .rules-table thead { - display: none; - } - .rules-table td::before { - content: attr(data-label) ": "; - font-weight: bold; - } -} {% endblock %} {% block content %} -

Explain a permission decision

+

Permission check

{% set current_tab = "check" %} {% include "_permissions_debug_tabs.html" %} -

Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.

+

Use this tool to test permission checks for the current actor. It queries the /-/check.json API endpoint.

+ +{% if request.actor %} +

Current actor: {{ request.actor.get("id", "anonymous") }}

+{% else %} +

Current actor: anonymous (not logged in)

+{% endif %}
-
+
- - - Use null for an anonymous actor. This actor is simulated; it does not change who you are signed in as. -
- -
- + - The operation to evaluate + The permission action to check
-
- +
+ - The database or other parent resource + For database-level permissions, specify the database name
-
- - - The table, query or other child resource +
+ + + For table-level permissions, specify the table name (requires parent)
- +
+ {% endblock %} diff --git a/datasette/templates/debug_permissions_playground.html b/datasette/templates/debug_permissions_playground.html index 8b0cbbcf..4410a677 100644 --- a/datasette/templates/debug_permissions_playground.html +++ b/datasette/templates/debug_permissions_playground.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Permission activity{% endblock %} +{% block title %}Debug permissions{% endblock %} {% block extra_head %} {% include "_permission_ui_styles.html" %} @@ -20,45 +20,60 @@ .check-action, .check-when, .check-result { font-size: 1.3em; } +textarea { + height: 10em; + width: 95%; + box-sizing: border-box; + padding: 0.5em; + border: 2px dotted black; +} +.two-col { + display: inline-block; + width: 48%; +} +.two-col label { + width: 48%; +} +@media only screen and (max-width: 576px) { + .two-col { + width: 100%; + } +} {% endblock %} {% block content %} -

Permission activity

+

Permission playground

{% set current_tab = "permissions" %} {% include "_permissions_debug_tabs.html" %} -

Raw simulator

- -

This form runs a hypothetical permission check and returns its raw explanation JSON. Use the Explain tool for a visual explanation of the same decision.

+

This tool lets you simulate an actor and a permission check for that actor.

-
-
-
- - -
+
+
+ +
-
-
- - -
-
- - -
-
- - -
+
+
+
+ + +
+
+ + +
+
+ +
@@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) { }); -

Recent permission checks

+

Recent permissions checks

{% if filter != "all" %}All{% else %}All{% endif %}, diff --git a/datasette/templates/debug_rules.html b/datasette/templates/debug_rules.html index 233c0e94..d00ba9cc 100644 --- a/datasette/templates/debug_rules.html +++ b/datasette/templates/debug_rules.html @@ -37,7 +37,7 @@

- + Number of results per page (max 200)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 18d3ba52..42574d3b 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -636,7 +636,7 @@ def detect_primary_keys(conn, table): def get_outbound_foreign_keys(conn, table): - infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall() + infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall() fks = [] for info in infos: if info is not None: diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py index d767e391..c7137e6b 100644 --- a/datasette/utils/actions_sql.py +++ b/datasette/utils/actions_sql.py @@ -252,62 +252,88 @@ async def _build_single_action_sql( ] ) - # Continue with the cascading logic. - # Aggregate the RULES by cascade level (small), rather than grouping - # base x rules (which scales with the number of resources). - def _agg(select_key, where, group_by): - parts = [ - f" SELECT {select_key}", - " MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,", - " MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,", - " json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,", - " json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons", - f" FROM all_rules WHERE {where}", - ] - if group_by: - parts.append(f" GROUP BY {group_by}") - return parts - + # Continue with the cascading logic query_parts.extend( - ["child_agg AS ("] - + _agg( - "parent, child,", - "parent IS NOT NULL AND child IS NOT NULL", - "parent, child", - ) - + ["),", "parent_agg AS ("] - + _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent") - + ["),", "global_agg AS ("] - + _agg("", "parent IS NULL AND child IS NULL", None) - + ["),"] + [ + "child_lvl AS (", + " SELECT b.parent, b.child,", + " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", + " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,", + " json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,", + " json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons", + " FROM base b", + " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child", + " GROUP BY b.parent, b.child", + "),", + "parent_lvl AS (", + " SELECT b.parent, b.child,", + " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", + " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,", + " json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,", + " json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons", + " FROM base b", + " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL", + " GROUP BY b.parent, b.child", + "),", + "global_lvl AS (", + " SELECT b.parent, b.child,", + " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", + " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,", + " json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,", + " json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons", + " FROM base b", + " LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL", + " GROUP BY b.parent, b.child", + "),", + ] ) # Add anonymous decision logic if needed if include_is_private: - - def _anon_agg(select_key, where, group_by): - parts = [ - f" SELECT {select_key}", - " MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,", - " MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow", - f" FROM anon_rules WHERE {where}", - ] - if group_by: - parts.append(f" GROUP BY {group_by}") - return parts - query_parts.extend( - ["anon_child_agg AS ("] - + _anon_agg( - "parent, child,", - "parent IS NOT NULL AND child IS NOT NULL", - "parent, child", - ) - + ["),", "anon_parent_agg AS ("] - + _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent") - + ["),", "anon_global_agg AS ("] - + _anon_agg("", "parent IS NULL AND child IS NULL", None) - + ["),"] + [ + "anon_child_lvl AS (", + " SELECT b.parent, b.child,", + " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", + " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow", + " FROM base b", + " LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child", + " GROUP BY b.parent, b.child", + "),", + "anon_parent_lvl AS (", + " SELECT b.parent, b.child,", + " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", + " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow", + " FROM base b", + " LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL", + " GROUP BY b.parent, b.child", + "),", + "anon_global_lvl AS (", + " SELECT b.parent, b.child,", + " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", + " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow", + " FROM base b", + " LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL", + " GROUP BY b.parent, b.child", + "),", + "anon_decisions AS (", + " SELECT", + " b.parent, b.child,", + " CASE", + " WHEN acl.any_deny = 1 THEN 0", + " WHEN acl.any_allow = 1 THEN 1", + " WHEN apl.any_deny = 1 THEN 0", + " WHEN apl.any_allow = 1 THEN 1", + " WHEN agl.any_deny = 1 THEN 0", + " WHEN agl.any_allow = 1 THEN 1", + " ELSE 0", + " END AS anon_is_allowed", + " FROM base b", + " JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))", + " JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))", + " JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))", + "),", + ] ) # Final decisions @@ -316,28 +342,31 @@ async def _build_single_action_sql( "decisions AS (", " SELECT", " b.parent, b.child,", - " -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level", + " -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level", " -- Priority order:", - " -- 1. Child-level deny 2. Child-level allow", - " -- 3. Parent-level deny 4. Parent-level allow", - " -- 5. Global-level deny 6. Global-level allow", + " -- 1. Child-level deny (most specific, blocks access)", + " -- 2. Child-level allow (most specific, grants access)", + " -- 3. Parent-level deny (intermediate, blocks access)", + " -- 4. Parent-level allow (intermediate, grants access)", + " -- 5. Global-level deny (least specific, blocks access)", + " -- 6. Global-level allow (least specific, grants access)", " -- 7. Default deny (no rules match)", " CASE", - " WHEN ca.any_deny = 1 THEN 0", - " WHEN ca.any_allow = 1 THEN 1", - " WHEN pa.any_deny = 1 THEN 0", - " WHEN pa.any_allow = 1 THEN 1", - " WHEN ga.any_deny = 1 THEN 0", - " WHEN ga.any_allow = 1 THEN 1", + " WHEN cl.any_deny = 1 THEN 0", + " WHEN cl.any_allow = 1 THEN 1", + " WHEN pl.any_deny = 1 THEN 0", + " WHEN pl.any_allow = 1 THEN 1", + " WHEN gl.any_deny = 1 THEN 0", + " WHEN gl.any_allow = 1 THEN 1", " ELSE 0", " END AS is_allowed,", " CASE", - " WHEN ca.any_deny = 1 THEN ca.deny_reasons", - " WHEN ca.any_allow = 1 THEN ca.allow_reasons", - " WHEN pa.any_deny = 1 THEN pa.deny_reasons", - " WHEN pa.any_allow = 1 THEN pa.allow_reasons", - " WHEN ga.any_deny = 1 THEN ga.deny_reasons", - " WHEN ga.any_allow = 1 THEN ga.allow_reasons", + " WHEN cl.any_deny = 1 THEN cl.deny_reasons", + " WHEN cl.any_allow = 1 THEN cl.allow_reasons", + " WHEN pl.any_deny = 1 THEN pl.deny_reasons", + " WHEN pl.any_allow = 1 THEN pl.allow_reasons", + " WHEN gl.any_deny = 1 THEN gl.deny_reasons", + " WHEN gl.any_allow = 1 THEN gl.allow_reasons", " ELSE '[]'", " END AS reason", ] @@ -345,34 +374,21 @@ async def _build_single_action_sql( if include_is_private: query_parts.append( - " , CASE WHEN (" - "CASE" - " WHEN aca.any_deny = 1 THEN 0" - " WHEN aca.any_allow = 1 THEN 1" - " WHEN apa.any_deny = 1 THEN 0" - " WHEN apa.any_allow = 1 THEN 1" - " WHEN aga.any_deny = 1 THEN 0" - " WHEN aga.any_allow = 1 THEN 1" - " ELSE 0 END" - ") = 0 THEN 1 ELSE 0 END AS is_private" + " , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private" ) query_parts.extend( [ " FROM base b", - " LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child", - " LEFT JOIN parent_agg pa ON pa.parent = b.parent", - " CROSS JOIN global_agg ga", + " JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))", + " JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))", + " JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))", ] ) if include_is_private: - query_parts.extend( - [ - " LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child", - " LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent", - " CROSS JOIN anon_global_agg aga", - ] + query_parts.append( + " JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))" ) query_parts.append(")") @@ -384,28 +400,8 @@ async def _build_single_action_sql( restriction_intersect = "\nINTERSECT\n".join( f"SELECT * FROM ({sql})" for sql in restriction_sqls ) - # Decompose by NULL-pattern so the final filter can use pure-equality - # EXISTS lookups (satisfiable via automatic indexes) instead of a - # correlated OR-scan over the whole list. query_parts.extend( - [ - ",", - "restriction_list AS (", - f" {restriction_intersect}", - "),", - "restriction_exact AS (", - " SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL", - "),", - "restriction_parent_any AS (", - " SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL", - "),", - "restriction_child_any AS (", - " SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL", - "),", - "restriction_all AS (", - " SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1", - ")", - ] + [",", "restriction_list AS (", f" {restriction_intersect}", ")"] ) # Final SELECT @@ -420,11 +416,10 @@ async def _build_single_action_sql( # Add restriction filter if there are restrictions if restriction_sqls: query_parts.append(""" - AND ( - EXISTS (SELECT 1 FROM restriction_all) - OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent) - OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child) - OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child) + AND EXISTS ( + SELECT 1 FROM restriction_list r + WHERE (r.parent = decisions.parent OR r.parent IS NULL) + AND (r.child = decisions.child OR r.child IS NULL) )""") # Add parent filter if specified @@ -678,239 +673,3 @@ async def check_permission_for_resource( child=child, ) return results[action] - - -async def explain_permission_for_resource( - *, - datasette: "Datasette", - actor: dict | None, - action: str, - parent: str | None, - child: str | None, -) -> dict: - """Explain a permission decision for one action and resource. - - This is intended for Datasette's permission debugging tools. It uses the - same ``permission_resources_sql`` hook results and the same resolution - rules as :func:`check_permissions_for_actions`, but also returns the - matching rules, actor restriction results and ``also_requires`` chain. - - The returned dictionary is part of Datasette's unstable debugging API. - """ - - action_obj = datasette.actions.get(action) - if action_obj is None: - raise ValueError(f"Unknown action: {action}") - - explanation = await _explain_single_action( - datasette=datasette, - actor=actor, - action=action, - parent=parent, - child=child, - ) - - required_actions = [] - if action_obj.also_requires: - required = await explain_permission_for_resource( - datasette=datasette, - actor=actor, - action=action_obj.also_requires, - parent=parent, - child=child, - ) - required_actions.append(required) - - explanation["required_actions"] = required_actions - explanation["allowed"] = bool( - explanation["rule_allowed"] - and explanation["restriction_allowed"] - and all(required["allowed"] for required in required_actions) - ) - explanation["summary"] = _permission_explanation_summary(explanation) - return explanation - - -async def _explain_single_action( - *, - datasette: "Datasette", - actor: dict | None, - action: str, - parent: str | None, - child: str | None, -) -> dict: - """Return matching rules and restrictions for a single action.""" - from datasette.utils.permissions import SKIP_PERMISSION_CHECKS - - permission_sqls = await gather_permission_sql_from_hooks( - datasette=datasette, - actor=actor, - action=action, - ) - - if permission_sqls is SKIP_PERMISSION_CHECKS: - return { - "action": action, - "rule_allowed": True, - "restriction_allowed": True, - "winning_scope": "global", - "matched_rules": [ - { - "scope": "global", - "effect": "allow", - "source": "skip_permission_checks", - "reason": "Permission checks were explicitly skipped", - "decisive": True, - "ignored_because": None, - } - ], - "restrictions": [], - } - - db = datasette.get_internal_database() - matched_rules = [] - restrictions = [] - - for permission_sql in permission_sqls: - params = dict(permission_sql.params or {}) - parent_param = _unused_parameter_name(params, "_explain_parent") - params[parent_param] = parent - child_param = _unused_parameter_name(params, "_explain_child") - params[child_param] = child - - if permission_sql.sql: - rows = await db.execute( - f""" - SELECT parent, child, allow, reason - FROM ({permission_sql.sql}) AS permission_rules - WHERE (parent IS NULL OR parent = :{parent_param}) - AND (child IS NULL OR child = :{child_param}) - """, - params, - ) - for row in rows: - specificity = ( - 2 - if row["child"] is not None - else 1 if row["parent"] is not None else 0 - ) - matched_rules.append( - { - "scope": ("resource", "parent", "global")[2 - specificity], - "effect": "allow" if row["allow"] else "deny", - "source": permission_sql.source, - "reason": row["reason"], - "_specificity": specificity, - } - ) - - if permission_sql.restriction_sql: - restriction_row = ( - await db.execute( - f""" - SELECT EXISTS( - SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules - WHERE (parent IS NULL OR parent = :{parent_param}) - AND (child IS NULL OR child = :{child_param}) - ) AS resource_is_in_allowlist - """, - params, - ) - ).first() - restriction_allowed = bool(restriction_row[0]) - restrictions.append( - { - "source": permission_sql.source, - "allowed": restriction_allowed, - "reason": params.get("deny") - or ( - "Resource is included in this restriction allowlist" - if restriction_allowed - else "Resource is not included in this restriction allowlist" - ), - } - ) - - matched_rules.sort( - key=lambda rule: ( - -rule["_specificity"], - 0 if rule["effect"] == "deny" else 1, - rule["source"] or "", - rule["reason"] or "", - ) - ) - - if matched_rules: - winning_specificity = matched_rules[0]["_specificity"] - winning_rules = [ - rule - for rule in matched_rules - if rule["_specificity"] == winning_specificity - ] - rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules) - winning_scope = winning_rules[0]["scope"] - else: - winning_specificity = None - rule_allowed = False - winning_scope = None - - for rule in matched_rules: - specificity = rule.pop("_specificity") - if specificity != winning_specificity: - rule["decisive"] = False - rule["ignored_because"] = "A more specific rule matched" - elif not rule_allowed and rule["effect"] == "allow": - rule["decisive"] = False - rule["ignored_because"] = "A deny rule matched at the same scope" - else: - rule["decisive"] = True - rule["ignored_because"] = None - - return { - "action": action, - "rule_allowed": rule_allowed, - "restriction_allowed": all( - restriction["allowed"] for restriction in restrictions - ), - "winning_scope": winning_scope, - "matched_rules": matched_rules, - "restrictions": restrictions, - } - - -def _unused_parameter_name(params: dict, preferred: str) -> str: - """Return a SQL parameter name that is not already in ``params``.""" - candidate = preferred - suffix = 2 - while candidate in params: - candidate = f"{preferred}_{suffix}" - suffix += 1 - return candidate - - -def _permission_explanation_summary(explanation: dict) -> str: - denied_requirement = next( - ( - required - for required in explanation["required_actions"] - if not required["allowed"] - ), - None, - ) - if denied_requirement: - return ( - f"Denied because {explanation['action']} also requires " - f"{denied_requirement['action']}, which was denied." - ) - if not explanation["matched_rules"]: - return "Denied because no permission rule matched this actor and resource." - if not explanation["rule_allowed"]: - return ( - f"Denied by a {explanation['winning_scope']}-level rule. " - "Deny rules take precedence over allow rules at the same scope." - ) - if not explanation["restriction_allowed"]: - return ( - "Denied because the resource is not included in the actor's restrictions." - ) - return f"Allowed by the matching {explanation['winning_scope']}-level rule." diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index 702b53d8..10ca32a5 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -3,7 +3,7 @@ import textwrap from sqlite_utils import Database as SQLiteUtilsDatabase from sqlite_utils import Migrations -from datasette.utils import escape_sqlite, table_column_details +from datasette.utils import table_column_details INTERNAL_DB_SCHEMA_TABLES = { "catalog_databases", @@ -180,9 +180,29 @@ async def init_internal_db(db): await db.execute_write_fn(apply_migrations, transaction=False) -async def populate_schema_tables(internal_db, db, schema_version): +async def populate_schema_tables(internal_db, db): database_name = db.name + def delete_everything(conn): + conn.execute( + "DELETE FROM catalog_tables WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_views WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_columns WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_foreign_keys WHERE database_name = ?", + [database_name], + ) + conn.execute( + "DELETE FROM catalog_indexes WHERE database_name = ?", [database_name] + ) + + await internal_db.execute_write_fn(delete_everything) + tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows @@ -213,7 +233,7 @@ async def populate_schema_tables(internal_db, db, schema_version): for column in columns ) foreign_keys = conn.execute( - f"PRAGMA foreign_key_list({escape_sqlite(table_name)})" + f"PRAGMA foreign_key_list([{table_name}])" ).fetchall() foreign_keys_to_insert.extend( { @@ -222,9 +242,7 @@ async def populate_schema_tables(internal_db, db, schema_version): } for foreign_key in foreign_keys ) - indexes = conn.execute( - f"PRAGMA index_list({escape_sqlite(table_name)})" - ).fetchall() + indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall() indexes_to_insert.extend( { **{"database_name": database_name, "table_name": table_name}, @@ -248,76 +266,47 @@ async def populate_schema_tables(internal_db, db, schema_version): indexes_to_insert, ) = await db.execute_fn(collect_info) - def replace_catalog(conn): - # Delete child rows before their catalog_tables parents so this also - # works if a prepare_connection plugin enables foreign key enforcement. - for table in ( - "catalog_columns", - "catalog_foreign_keys", - "catalog_indexes", - "catalog_views", - "catalog_tables", - ): - conn.execute( - "DELETE FROM {} WHERE database_name = ?".format(table), - [database_name], - ) - conn.execute( - """ - INSERT OR REPLACE INTO catalog_databases ( - database_name, path, is_memory, schema_version - ) VALUES (?, ?, ?, ?) - """, - [ - database_name, - str(db.path) if db.path is not None else None, - db.is_memory, - schema_version, - ], + await internal_db.execute_write_many( + """ + INSERT INTO catalog_tables (database_name, table_name, rootpage, sql) + values (?, ?, ?, ?) + """, + tables_to_insert, + ) + await internal_db.execute_write_many( + """ + INSERT INTO catalog_views (database_name, view_name, rootpage, sql) + values (?, ?, ?, ?) + """, + views_to_insert, + ) + await internal_db.execute_write_many( + """ + INSERT INTO catalog_columns ( + database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden + ) VALUES ( + :database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden ) - conn.executemany( - """ - INSERT INTO catalog_tables (database_name, table_name, rootpage, sql) - values (?, ?, ?, ?) - """, - tables_to_insert, + """, + columns_to_insert, + ) + await internal_db.execute_write_many( + """ + INSERT INTO catalog_foreign_keys ( + database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match + ) VALUES ( + :database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match ) - conn.executemany( - """ - INSERT INTO catalog_views (database_name, view_name, rootpage, sql) - values (?, ?, ?, ?) - """, - views_to_insert, + """, + foreign_keys_to_insert, + ) + await internal_db.execute_write_many( + """ + INSERT INTO catalog_indexes ( + database_name, table_name, seq, name, "unique", origin, partial + ) VALUES ( + :database_name, :table_name, :seq, :name, :unique, :origin, :partial ) - conn.executemany( - """ - INSERT INTO catalog_columns ( - database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden - ) VALUES ( - :database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden - ) - """, - columns_to_insert, - ) - conn.executemany( - """ - INSERT INTO catalog_foreign_keys ( - database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match - ) VALUES ( - :database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match - ) - """, - foreign_keys_to_insert, - ) - conn.executemany( - """ - INSERT INTO catalog_indexes ( - database_name, table_name, seq, name, "unique", origin, partial - ) VALUES ( - :database_name, :table_name, :seq, :name, :unique, :origin, :partial - ) - """, - indexes_to_insert, - ) - - await internal_db.execute_write_fn(replace_catalog) + """, + indexes_to_insert, + ) diff --git a/datasette/version.py b/datasette/version.py index 8e238ab5..387144e9 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a37" +__version__ = "1.0a36" __version_info__ = tuple(__version__.split(".")) diff --git a/datasette/views/database.py b/datasette/views/database.py index 11646f45..10dc66ae 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -643,15 +643,8 @@ class QueryView(View): ok = None redirect_url = None try: - execute_write_kwargs = {"request": request} - if stored_query.is_trusted: - analysis = await db.analyze_sql(stored_query.sql, params_for_query) - if any( - operation.operation == "vacuum" for operation in analysis.operations - ): - execute_write_kwargs["transaction"] = False cursor = await db.execute_write( - stored_query.sql, params_for_query, **execute_write_kwargs + stored_query.sql, params_for_query, request=request ) # success message can come from on_success_message or on_success_message_sql message = None diff --git a/datasette/views/special.py b/datasette/views/special.py index 28d34208..c13191a1 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -600,7 +600,7 @@ class PermissionRulesView(BaseView): async def _check_permission_for_actor(ds, action, parent, child, actor): - """Shared logic for checking and explaining a permission decision.""" + """Shared logic for checking permissions. Returns a dict with check results.""" if action not in ds.actions: return error_body(f"Unknown action: {action}", 404), 404 @@ -629,28 +629,15 @@ async def _check_permission_for_actor(ds, action, parent, child, actor): allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor) - from datasette.utils.actions_sql import explain_permission_for_resource - - explanation = await explain_permission_for_resource( - datasette=ds, - actor=actor, - action=action, - parent=parent, - child=child, - ) - response = { "ok": True, - "unstable": UNSTABLE_API_MESSAGE, "action": action, "allowed": bool(allowed), - "actor": actor, "resource": { "parent": parent, "child": child, "path": _resource_path(parent, child), }, - "explanation": explanation, } if actor and "id" in actor: @@ -668,25 +655,11 @@ class PermissionCheckView(BaseView): as_format = request.url_vars.get("format") if not as_format: - actions = [ - { - "name": action.name, - "description": action.description, - "takes_parent": action.takes_parent, - "takes_child": action.takes_child, - "also_requires": action.also_requires, - } - for action in sorted( - self.ds.actions.values(), key=lambda action: action.name - ) - ] return await self.render( ["debug_check.html"], request, { - "actions": actions, - "actor_json": request.args.get("actor") - or json.dumps(request.actor, indent=2), + "sorted_actions": sorted(self.ds.actions.keys()), "has_debug_permission": True, }, ) @@ -698,18 +671,9 @@ class PermissionCheckView(BaseView): parent = request.args.get("parent") child = request.args.get("child") - actor = request.actor - actor_json = request.args.get("actor") - if actor_json is not None: - try: - actor = json.loads(actor_json) - except json.JSONDecodeError as ex: - return Response.error(f"Invalid actor JSON: {ex}", 400) - if actor is not None and not isinstance(actor, dict): - return Response.error("actor must be a JSON object or null", 400) response, status = await _check_permission_for_actor( - self.ds, action, parent, child, actor + self.ds, action, parent, child, request.actor ) return Response.json(response, status=status) diff --git a/docs/authentication.rst b/docs/authentication.rst index d720c4db..51fa07d5 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -45,7 +45,7 @@ Using the "root" actor Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github `__ for example. -The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule. +The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules. The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including: @@ -84,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t Permissions =========== +Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access. + The key question the permissions system answers is this: Is this **actor** allowed to perform this **action**, optionally against this particular **resource**? -Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions. - **Actors** are :ref:`described above `. An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below ` - examples include ``view-table`` and ``execute-sql``. @@ -138,51 +138,7 @@ This configuration will deny access to everyone except the user with ``id`` of ` How permissions are resolved ---------------------------- -Permission rules describe an effect (``allow`` or ``deny``) at one of three levels: - -``resource`` - A specific child resource, such as the ``analytics/sales`` table. - -``parent`` - A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources. - -``global`` - Every resource for that action. - -Datasette resolves matching rules from most specific to least specific: - -#. Resource rules take precedence over parent and global rules. -#. Parent rules take precedence over global rules. -#. If both allow and deny rules match at the same level, deny takes precedence. -#. If no rule matches, access is denied. - -This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny. - -.. list-table:: Permission rule examples - :header-rows: 1 - - * - Matching rules - - Result - - Explanation - * - Global allow - - Allow - - The global rule is the most specific matching rule. - * - Global allow, parent deny - - Deny - - The parent rule is more specific. - * - Parent deny, resource allow - - Allow - - The resource rule is more specific. - * - Resource allow and resource deny - - Deny - - Deny takes precedence at the same level. - * - No matching rules - - Deny - - Permissions default to deny when no rule applies. - -The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules. - -Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. +Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. ``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified. @@ -193,12 +149,12 @@ resources were allowed or denied. The combined sources are: * ``allow`` blocks configured in :ref:`datasette.yaml `. * :ref:`Actor restrictions ` encoded into the actor dictionary or API token. -* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled `) is active. This is a global allow rule, so a more specific configuration deny can override it. +* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled `) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level. * Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`. -Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`. - -Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed. +Datasette evaluates the SQL to determine if the requested ``resource`` is +included. Explicit deny rules returned by configuration or plugins will block +access even if other rules allowed it. .. _authentication_permissions_allow: @@ -1189,21 +1145,11 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis datasette -s permissions.permissions-debug true data.db -The permission debug tools answer four different questions: +The page shows the permission checks that have been carried out by the Datasette instance. -Why was this decision allowed or denied? - Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions. +It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect. -Which resources can the current actor access? - Use :ref:`AllowedResourcesView` to view an access map for a selected action. - -Which raw rules did Datasette and its plugins contribute? - Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions. - -Which checks has this Datasette instance performed recently? - Use ``/-/permissions`` to view recent permission activity. - -These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration. +This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system. These debug endpoints are exempt from the :ref:`JSON API stability promise ` - their JSON shapes may change in future releases. @@ -1238,20 +1184,11 @@ This endpoint requires the ``permissions-debug`` permission. Permission check view --------------------- -The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes: - -* Every matching allow and deny rule, with its source and reason. -* The winning resource, parent or global scope. -* Rules ignored because a more specific rule matched, or because a deny won at the same scope. -* Actor restriction allowlists that included or excluded the resource. -* Additional actions required by the requested action. -* An explicit default-deny explanation when no rule matched. +The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information. This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead. -Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor. - -This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in. +Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. .. _authentication_ds_actor: diff --git a/docs/changelog.rst b/docs/changelog.rst index 670166bb..e3718543 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,20 +4,6 @@ Changelog ========= -.. _v1_0_a37: - -1.0a37 (2026-07-14) -------------------- - -Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface. - -- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`) -- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation ` now describes resolution rules in more detail. (:issue:`2841`) -- :ref:`db.execute_write(sql, ..., transaction=True) ` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`) -- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`) -- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy `__. (:issue:`2431`, :pr:`2846`) -- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`) - .. _v1_0_a36: 1.0a36 (2026-07-07) diff --git a/docs/internals.rst b/docs/internals.rst index d2bd46ef..2048c7e4 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2023,8 +2023,8 @@ Example usage: .. _database_execute_write: -await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True) --------------------------------------------------------------------------------------------------------------------------- +await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10) +-------------------------------------------------------------------------------------------------------- SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received. @@ -2059,9 +2059,7 @@ If you need to retrieve every row returned by a statement, pass ``return_all=Tru If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task. -Each call to ``execute_write()`` will be executed inside a transaction. Pass -``transaction=False`` for statements such as ``VACUUM`` that cannot run inside -a transaction. +Each call to ``execute_write()`` will be executed inside a transaction. .. _database_execute_write_script: diff --git a/docs/introspection.rst b/docs/introspection.rst index 14b6249f..b78e4860 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -80,15 +80,18 @@ Shows a list of currently installed plugins and their versions. `Plugins example .. code-block:: json - [ - { - "name": "datasette_cluster_map", - "static": true, - "templates": false, - "version": "0.10", - "hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"] - } - ] + { + "ok": true, + "plugins": [ + { + "name": "datasette_cluster_map", + "static": true, + "templates": false, + "version": "0.10", + "hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"] + } + ] + } Add ``?all=1`` to include details of the default plugins baked into Datasette. diff --git a/tests/test_api.py b/tests/test_api.py index 191d064a..5ed14283 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,6 @@ from datasette.app import Datasette from datasette.plugins import DEFAULT_PLUGINS -from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode +from datasette.utils import UNSTABLE_API_MESSAGE from datasette.utils.sqlite import sqlite_version from datasette.version import __version__ from .fixtures import make_app_client, EXPECTED_PLUGINS @@ -614,13 +614,13 @@ async def test_plugins_json(ds_client): response = await ds_client.get("/-/plugins.json") # Filter out TrackEventPlugin actual_plugins = sorted( - [p for p in response.json() if p["name"] != "TrackEventPlugin"], + [p for p in response.json()["plugins"] if p["name"] != "TrackEventPlugin"], key=lambda p: p["name"], ) assert EXPECTED_PLUGINS == actual_plugins # Try with ?all=1 response = await ds_client.get("/-/plugins.json?all=1") - names = {p["name"] for p in response.json()} + names = {p["name"] for p in response.json()["plugins"]} assert names.issuperset(p["name"] for p in EXPECTED_PLUGINS) assert names.issuperset(DEFAULT_PLUGINS) @@ -930,37 +930,6 @@ async def test_tilde_encoded_database_names(db_name): assert response2.status_code == 200 -@pytest.mark.asyncio -@pytest.mark.parametrize("table_name", ("[foo]", "foo]", "[foo]/bar")) -async def test_table_with_reserved_characters_in_name(table_name): - # Table names containing characters such as "]" that cannot be escaped - # using SQLite [bracket] quoting used to break schema introspection and - # the table page - https://github.com/simonw/datasette/issues/2431 - ds = Datasette() - db = ds.add_memory_database("test_reserved_table_names") - await db.execute_write( - "create table {} (id integer primary key, name text)".format( - escape_sqlite(table_name) - ) - ) - await db.execute_write( - "insert into {} (id, name) values (1, 'one')".format(escape_sqlite(table_name)) - ) - # Schema introspection (populate_schema_tables) must not crash: - db_response = await ds.client.get("/test_reserved_table_names.json") - assert db_response.status_code == 200 - tables = {t["name"]: t for t in db_response.json()["tables"]} - assert tables[table_name]["count"] == 1 - # And the table page itself must load and return the row: - table_response = await ds.client.get( - "/test_reserved_table_names/{}.json?_shape=array".format( - tilde_encode(table_name) - ) - ) - assert table_response.status_code == 200 - assert table_response.json() == [{"id": 1, "name": "one"}] - - @pytest.mark.asyncio @pytest.mark.parametrize( "config,expected", diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index 636b17eb..42c6ae60 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -109,7 +109,7 @@ def test_settings(config_dir_client): def test_plugins(config_dir_client): response = config_dir_client.get("/-/plugins.json") assert 200 == response.status - plugins = response.json + plugins = response.json["plugins"] assert "hooray.py" in {p["name"] for p in plugins} assert "non_py_file.txt" not in {p["name"] for p in plugins} assert "mypy_cache" not in {p["name"] for p in plugins} diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index b1a212d9..bad4e8ca 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -11,7 +11,6 @@ from datasette.database import _deliver_write_result from datasette.utils.sqlite import sqlite3, supports_returning from datasette.utils import Column import pytest -import sqlite_utils import time import uuid @@ -719,41 +718,6 @@ async def test_execute_write_fn_exception(db): await db.execute_write_fn(write_fn) -@pytest.mark.asyncio -@pytest.mark.parametrize("num_sql_threads", (0, 1)) -async def test_execute_write_fn_sqlite_utils_transaction(tmp_path, num_sql_threads): - # A write inside a failing Datasette task must never become visible or - # survive the rollback. Exercise both the synchronous and writer-thread - # paths against a file-backed database so a second connection can observe - # committed state independently. - db_path = tmp_path / "test.db" - sqlite3.connect(db_path).close() - ds = Datasette([str(db_path)], settings={"num_sql_threads": num_sql_threads}) - db = ds.get_database("test") - await db.execute_write("create table items (id integer primary key)") - # This reader is used inside the write callback, which may run on another - # thread, but it is never accessed concurrently. - reader = sqlite3.connect(db_path, check_same_thread=False) - - def insert_then_fail(conn): - # Datasette must open the outer transaction before sqlite-utils writes. - assert conn.in_transaction - sqlite_utils.Database(conn)["items"].insert({"id": 1}) - # If sqlite-utils committed its own transaction, this would return 1. - assert reader.execute("select count(*) from items").fetchone()[0] == 0 - # Simulate a later step failing after the sqlite-utils write succeeded. - raise ValueError("deliberate") - - try: - with pytest.raises(ValueError, match="deliberate"): - await db.execute_write_fn(insert_then_fail) - # The outer transaction must roll back the sqlite-utils write as well. - assert reader.execute("select count(*) from items").fetchone()[0] == 0 - finally: - reader.close() - db.close() - - @pytest.mark.asyncio @pytest.mark.parametrize("param_name", ["conn", "connection", "db", "c"]) async def test_execute_write_fn_accepts_any_single_param_name(db, param_name): diff --git a/tests/test_permissions.py b/tests/test_permissions.py index cd1050d0..88fe577f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -457,20 +457,6 @@ async def test_permissions_debug(ds_client, filter_): assert checks == expected_checks -@pytest.mark.asyncio -@pytest.mark.parametrize( - "permissions_debug,expected_status", - ( - (1, 200), - (0, 403), - ), -) -async def test_permissions_debug_numeric_boolean(permissions_debug, expected_status): - ds = Datasette(config={"permissions": {"permissions-debug": permissions_debug}}) - response = await ds.client.get("/-/permissions") - assert response.status_code == expected_status - - @pytest.mark.asyncio @pytest.mark.parametrize( "actor,allow,expected_fragment", @@ -762,12 +748,7 @@ async def test_actor_restricted_permissions( } if actor.get("id"): expected["actor_id"] = actor["id"] - data = response.json() - for key, value in expected.items(): - assert data[key] == value - assert data["actor"] == actor - assert data["explanation"]["allowed"] is expected_result - assert data["explanation"]["summary"] + assert response.json() == expected PermConfigTestCase = collections.namedtuple( @@ -1753,8 +1734,6 @@ async def test_permission_check_view_requires_debug_permission(): data = response.json() assert data["action"] == "view-instance" assert data["allowed"] is True - assert data["explanation"]["allowed"] is True - assert data["explanation"]["summary"] @pytest.mark.asyncio @@ -1780,211 +1759,6 @@ async def test_permission_check_view_query_actions(action): } -@pytest.mark.asyncio -async def test_permission_check_explains_specificity_for_hypothetical_actor(): - ds = Datasette( - config={ - "permissions": {"view-table": {"id": "alice"}}, - "databases": { - "analytics": { - "permissions": {"view-table": False}, - "tables": { - "public": {"permissions": {"view-table": {"id": "alice"}}} - }, - } - }, - } - ) - ds.root_enabled = True - await ds.invoke_startup() - - def path_for(child): - return "/-/check.json?" + urllib.parse.urlencode( - { - "action": "view-table", - "parent": "analytics", - "child": child, - "actor": json.dumps({"id": "alice"}), - } - ) - - public_response = await ds.client.get(path_for("public"), actor={"id": "root"}) - assert public_response.status_code == 200 - public = public_response.json() - assert public["actor"] == {"id": "alice"} - assert public["allowed"] is True - assert public["explanation"]["allowed"] is True - assert public["explanation"]["winning_scope"] == "resource" - public_rules = public["explanation"]["matched_rules"] - assert any( - rule["scope"] == "resource" and rule["effect"] == "allow" and rule["decisive"] - for rule in public_rules - ) - assert any( - rule["scope"] == "parent" - and rule["effect"] == "deny" - and rule["ignored_because"] == "A more specific rule matched" - for rule in public_rules - ) - - private_response = await ds.client.get(path_for("private"), actor={"id": "root"}) - assert private_response.status_code == 200 - private = private_response.json() - assert private["allowed"] is False - assert private["explanation"]["allowed"] is False - assert private["explanation"]["winning_scope"] == "parent" - assert private["explanation"]["summary"].startswith("Denied by a parent-level rule") - - -@pytest.mark.asyncio -async def test_permission_check_explains_deny_wins_at_same_scope(): - ds = Datasette(config={"permissions": {"view-table": {"id": "someone-else"}}}) - ds.root_enabled = True - await ds.invoke_startup() - path = "/-/check.json?" + urllib.parse.urlencode( - { - "action": "view-table", - "parent": "analytics", - "child": "users", - "actor": json.dumps({"id": "alice"}), - } - ) - response = await ds.client.get(path, actor={"id": "root"}) - assert response.status_code == 200 - data = response.json() - assert data["allowed"] is False - assert data["explanation"]["winning_scope"] == "global" - rules = data["explanation"]["matched_rules"] - assert any(rule["effect"] == "deny" and rule["decisive"] for rule in rules) - assert any( - rule["effect"] == "allow" - and rule["ignored_because"] == "A deny rule matched at the same scope" - for rule in rules - ) - - -@pytest.mark.asyncio -async def test_permission_check_explains_default_deny(): - ds = Datasette() - ds.root_enabled = True - await ds.invoke_startup() - path = "/-/check.json?" + urllib.parse.urlencode( - { - "action": "insert-row", - "parent": "analytics", - "child": "users", - "actor": json.dumps({"id": "alice"}), - } - ) - response = await ds.client.get(path, actor={"id": "root"}) - assert response.status_code == 200 - data = response.json() - assert data["allowed"] is False - explanation = data["explanation"] - assert explanation["allowed"] is False - assert explanation["matched_rules"] == [] - assert explanation["winning_scope"] is None - assert explanation["summary"] == ( - "Denied because no permission rule matched this actor and resource." - ) - - -@pytest.mark.asyncio -async def test_permission_check_explains_actor_restrictions(): - ds = Datasette() - ds.root_enabled = True - await ds.invoke_startup() - restricted_actor = { - "id": "alice", - "_r": {"r": {"analytics": {"public": ["vt"]}}}, - } - path = "/-/check.json?" + urllib.parse.urlencode( - { - "action": "view-table", - "parent": "analytics", - "child": "private", - "actor": json.dumps(restricted_actor), - } - ) - response = await ds.client.get(path, actor={"id": "root"}) - assert response.status_code == 200 - data = response.json() - assert data["allowed"] is False - explanation = data["explanation"] - assert explanation["rule_allowed"] is True - assert explanation["restriction_allowed"] is False - assert explanation["allowed"] is False - assert explanation["restrictions"] - assert any( - restriction["allowed"] is False for restriction in explanation["restrictions"] - ) - assert "actor's restrictions" in explanation["summary"] - - -@pytest.mark.asyncio -async def test_permission_check_explains_required_actions(): - from datasette import hookimpl - from datasette.permissions import PermissionSQL - - class StoreQueryPermissions: - @hookimpl - def permission_resources_sql(self, actor, action): - if not actor or actor.get("id") != "alice": - return None - if action == "store-query": - return PermissionSQL( - sql="SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'alice can store queries' AS reason" - ) - if action == "execute-sql": - return PermissionSQL( - sql="SELECT 'analytics' AS parent, NULL AS child, 0 AS allow, 'alice cannot execute SQL' AS reason" - ) - - ds = Datasette() - ds.root_enabled = True - await ds.invoke_startup() - ds.pm.register(StoreQueryPermissions(), name="store-query-test") - path = "/-/check.json?" + urllib.parse.urlencode( - { - "action": "store-query", - "parent": "analytics", - "actor": json.dumps({"id": "alice"}), - } - ) - response = await ds.client.get(path, actor={"id": "root"}) - assert response.status_code == 200 - data = response.json() - assert data["allowed"] is False - explanation = data["explanation"] - assert explanation["rule_allowed"] is True - assert explanation["required_actions"][0]["action"] == "execute-sql" - assert explanation["required_actions"][0]["allowed"] is False - assert explanation["summary"] == ( - "Denied because store-query also requires execute-sql, which was denied." - ) - - -@pytest.mark.asyncio -async def test_permission_check_hypothetical_actor_validation(): - ds = Datasette() - ds.root_enabled = True - await ds.invoke_startup() - - response = await ds.client.get( - "/-/check.json?action=view-instance&actor=not-json", - actor={"id": "root"}, - ) - assert response.status_code == 400 - assert response.json()["error"].startswith("Invalid actor JSON:") - - response = await ds.client.get( - "/-/check.json?action=view-instance&actor=%5B%5D", - actor={"id": "root"}, - ) - assert response.status_code == 400 - assert response.json()["error"] == "actor must be a JSON object or null" - - @pytest.mark.asyncio async def test_root_allow_block_with_table_restricted_actor(): """ diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 59b1c0bf..5c4034db 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1482,7 +1482,7 @@ async def test_plugin_is_installed(): datasette.pm.register(DummyPlugin(), name="DummyPlugin") response = await datasette.client.get("/-/plugins.json") assert response.status_code == 200 - installed_plugins = {p["name"] for p in response.json()} + installed_plugins = {p["name"] for p in response.json()["plugins"]} assert "DummyPlugin" in installed_plugins finally: diff --git a/tests/test_success_envelope.py b/tests/test_success_envelope.py index 46a78ef0..3c413d73 100644 --- a/tests/test_success_envelope.py +++ b/tests/test_success_envelope.py @@ -2,8 +2,8 @@ Tests for the canonical JSON success envelope. Every JSON object returned by a Datasette endpoint on success should include -"ok": true. /-/plugins intentionally returns a top-level array instead, while -/-/databases and /-/actions use the object envelope. +"ok": true. (Endpoints that return a top-level array are being converted to +objects separately - see /-/plugins, /-/databases, /-/actions.) """ import pytest @@ -79,16 +79,17 @@ async def test_permissions_post_success_has_ok_true(ds_envelope): @pytest.mark.asyncio -async def test_plugins_json_is_array(ds_client): +async def test_plugins_json_is_object(ds_client): response = await ds_client.get("/-/plugins.json") assert response.status_code == 200 data = response.json() - assert isinstance(data, list) - assert all(isinstance(plugin, dict) for plugin in data) + assert set(data.keys()) == {"ok", "plugins"} + assert data["ok"] is True + assert isinstance(data["plugins"], list) # ?all=1 should include Datasette's default plugins in the same shape response_all = await ds_client.get("/-/plugins.json?all=1") - all_plugins = response_all.json() - assert len(all_plugins) > len(data) + all_plugins = response_all.json()["plugins"] + assert len(all_plugins) > len(data["plugins"]) @pytest.mark.asyncio