Fix #2509: Settings-based deny rules now override root user privileges

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 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2025-10-24 10:56:51 -07:00
commit 7c6bc0b902
2 changed files with 92 additions and 12 deletions

View file

@ -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)

View file

@ -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 "<!DOCTYPE html>" in text or "<html" in text
@pytest.mark.asyncio
async def test_root_user_respects_settings_deny():
"""
Test for issue #2509: Settings-based deny rules should override root user privileges.
When a database has `allow: false` in settings, the root user should NOT see
that database in /-/allowed.json?action=view-database, even though root normally
has all permissions.
"""
ds = Datasette(
config={
"databases": {
"content": {
"allow": False, # Deny everyone, including root
}
}
}
)
ds.root_enabled = True
await ds.invoke_startup()
ds.add_memory_database("content")
# Root user should NOT see the content database because settings deny it
response = await ds.client.get(
"/-/allowed.json?action=view-database",
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
)
assert response.status_code == 200
data = response.json()
# Check that content database is NOT in the allowed list
allowed_databases = [item["parent"] for item in data["items"]]
assert "content" not in allowed_databases, (
f"Root user should not see 'content' database when settings deny it, "
f"but found it in: {allowed_databases}"
)
@pytest.mark.asyncio
async def test_root_user_respects_settings_deny_tables():
"""
Test for issue #2509: Settings-based deny rules should override root for tables too.
When a database has `allow: false` in settings, the root user should NOT see
tables from that database in /-/allowed.json?action=view-table.
"""
ds = Datasette(
config={
"databases": {
"content": {
"allow": False, # Deny everyone, including root
}
}
}
)
ds.root_enabled = True
await ds.invoke_startup()
# Add a database with a table
db = ds.add_memory_database("content")
await db.execute_write("CREATE TABLE repos (id INTEGER PRIMARY KEY, name TEXT)")
await ds.refresh_schemas()
# Root user should NOT see tables from the content database
response = await ds.client.get(
"/-/allowed.json?action=view-table",
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
)
assert response.status_code == 200
data = response.json()
# Check that content.repos table is NOT in the allowed list
content_tables = [
item["child"] for item in data["items"] if item["parent"] == "content"
]
assert "repos" not in content_tables, (
f"Root user should not see tables from 'content' database when settings deny it, "
f"but found: {content_tables}"
)