From 16b2729847347800cbaf6652362e0b6e2fd354e2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 24 Oct 2025 10:56:51 -0700 Subject: [PATCH] Fix #2509: Settings-based deny rules now override root user privileges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root user's permission_resources_sql hook was returning early with a blanket "allow all" rule, preventing settings-based deny rules from being considered. This caused /-/allowed and /-/rules endpoints to incorrectly show resources that were denied via settings. Changed permission_resources_sql to append root permissions to the rules list instead of returning early, allowing config-based deny rules to be evaluated. The SQL cascading logic correctly applies: deny rules at the same depth beat allow rules, so database-level denies override root's global-level allow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- datasette/default_permissions.py | 23 ++++----- tests/test_permission_endpoints.py | 81 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index e873361c..a37c47c1 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -203,23 +203,22 @@ def permission_allowed_root(datasette, actor, action, resource): @hookimpl async def permission_resources_sql(datasette, actor, action): - # Root user with root_enabled gets all permissions + rules: list[PermissionSQL] = [] + + # Root user with root_enabled gets all permissions at global level + # Config rules at more specific levels (database/table) can still override if datasette.root_enabled and actor and actor.get("id") == "root": - # Return SQL that grants access to ALL resources for this action - action_obj = datasette.actions.get(action) - if action_obj and action_obj.resource_class: - resources_sql = action_obj.resource_class.resources_sql() - sql = f""" - SELECT parent, child, 1 AS allow, 'root user' AS reason - FROM ({resources_sql}) - """ - return PermissionSQL( + # Add a single global-level allow rule (NULL, NULL) for root + # This allows root to access everything by default, but database-level + # and table-level deny rules in config can still block specific resources + sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'root user' AS reason" + rules.append( + PermissionSQL( source="root_permissions", sql=sql, params={}, ) - - rules: list[PermissionSQL] = [] + ) config_rules = await _config_permission_rules(datasette, actor, action) rules.extend(config_rules) diff --git a/tests/test_permission_endpoints.py b/tests/test_permission_endpoints.py index 0210fb95..33e7cd75 100644 --- a/tests/test_permission_endpoints.py +++ b/tests/test_permission_endpoints.py @@ -494,3 +494,84 @@ async def test_html_endpoints_return_html(ds_with_permissions, path, needs_debug # Check for HTML structure text = response.text assert "" in text or "