Skip to content

Instantly share code, notes, and snippets.

@simonw
Created October 24, 2025 18:02
Show Gist options
  • Select an option

  • Save simonw/fda40ff7293eab5eb5f94798ddad99bb to your computer and use it in GitHub Desktop.

Select an option

Save simonw/fda40ff7293eab5eb5f94798ddad99bb to your computer and use it in GitHub Desktop.
Terminal output HTML
<pre style="background: rgb(0, 0, 0); color: rgb(242, 242, 242); padding: 15px; border-radius: 4px;"><span style="color: rgb(242, 242, 242);">&gt; </span><span style="color: rgb(255, 255, 255); background: rgb(42, 42, 42);">Take a look at https://github.com/simonw/datasette/issues/2509 and </span><span style="color: rgb(227, 71, 70);">u</span><span style="color: rgb(240, 118, 70);">l</span><span style="color: rgb(247, 183, 77);">t</span><span style="color: rgb(129, 191, 111);">r</span><span style="color: rgb(112, 152, 212);">a</span><span style="color: rgb(136, 107, 188);">t</span><span style="color: rgb(186, 107, 165);">h</span><span style="color: rgb(227, 71, 70);">i</span><span style="color: rgb(240, 118, 70);">n</span><span style="color: rgb(247, 183, 77);">k</span><span style="color: rgb(255, 255, 255); background: rgb(42, 42, 42);"> about what is going on</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Fetch</span><span style="color: rgb(242, 242, 242);">(https://github.com/simonw/datasette/issues/2509)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Received </span><span style="color: rgb(255, 255, 255);">257.5KB</span><span style="color: rgb(242, 242, 242);"> (200 OK)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "/-/allowed")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">8 </span><span style="color: rgb(242, 242, 242);">files </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "async def allowed", path: "datasette/app.py", output_mode: "content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">110 </span><span style="color: rgb(242, 242, 242);">lines </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "class.*Allowed.*View|def.*allowed.*endpoint", output_mode: "content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">15 </span><span style="color: rgb(242, 242, 242);">lines </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Read</span><span style="color: rgb(242, 242, 242);">(datasette/views/special.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Read </span><span style="color: rgb(255, 255, 255);">100</span><span style="color: rgb(242, 242, 242);"> lines</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "class AllowedResourcesView", path: "datasette/views/special.py", output_mode: "content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">101 </span><span style="color: rgb(242, 242, 242);">lines </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Read</span><span style="color: rgb(242, 242, 242);">(datasette/views/special.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Read </span><span style="color: rgb(255, 255, 255);">100</span><span style="color: rgb(242, 242, 242);"> lines</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "def resolve_permissions_from_catalog", output_mode: "content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">163 </span><span style="color: rgb(242, 242, 242);">lines </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "settings.*allow|permissions.*settings")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">7 </span><span style="color: rgb(242, 242, 242);">files </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "def.*permission_allowed|permission_allowed_sql", path: "datasette/app.py", output_mode: "content",</span>
<span style="color: rgb(242, 242, 242);"> head_limit: 100)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">94 </span><span style="color: rgb(242, 242, 242);">lines </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "@pm.hook</span>\<span style="color: rgb(242, 242, 242);">|register.*permission_resources_sql</span>\<span style="color: rgb(242, 242, 242);">|settings.*permission", path: "datasette/app.py",</span>
<span style="color: rgb(242, 242, 242);"> output_mode: "content", head_limit: 50)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">0 </span><span style="color: rgb(242, 242, 242);">lines </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "class.*DefaultPermissions|default_permissions")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">9 </span><span style="color: rgb(242, 242, 242);">files </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Read</span><span style="color: rgb(242, 242, 242);">(datasette/default_permissions.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Read </span><span style="color: rgb(255, 255, 255);">567</span><span style="color: rgb(242, 242, 242);"> lines</span>
<span style="color: rgb(242, 242, 242);"> Now I understand what's happening! Let me write up my analysis:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Ultra-thinking on Issue #2509</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">The Problem</span>
<span style="color: rgb(242, 242, 242);"> When running:</span>
<span style="color: rgb(242, 242, 242);"> datasette content.db --root -s databases.content.allow </span><span style="color: rgb(64, 11, 217);">false</span>
<span style="color: rgb(242, 242, 242);"> - The </span><span style="color: rgb(162, 168, 247);">/-/allowed?action=view-table</span><span style="color: rgb(242, 242, 242);"> endpoint </span><span style="color: rgb(255, 255, 255);">incorrectly</span><span style="color: rgb(242, 242, 242);"> shows content database tables</span>
<span style="color: rgb(242, 242, 242);"> - But accessing </span><span style="color: rgb(162, 168, 247);">/content/repos</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">correctly</span><span style="color: rgb(242, 242, 242);"> returns 403</span>
<span style="color: rgb(242, 242, 242);"> - The </span><span style="color: rgb(162, 168, 247);">/-/rules?action=view-table</span><span style="color: rgb(242, 242, 242);"> endpoint doesn't show the deny rule</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">Root Cause</span>
<span style="color: rgb(242, 242, 242);"> The issue is in </span><span style="color: rgb(162, 168, 247);">/Users/simon/Dropbox/Development/datasette/datasette/default_permissions.py</span><span style="color: rgb(242, 242, 242);"> at </span><span style="color: rgb(255, 255, 255);">lines 206-220</span><span style="color: rgb(242, 242, 242);"> in the</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(162, 168, 247);">permission_resources_sql</span><span style="color: rgb(242, 242, 242);"> hook:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">async</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">def</span><span style="color: rgb(159, 160, 28);"> permission_resources_sql(datasette, actor, action):</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(47, 180, 29);"># Root user with root_enabled gets all permissions</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">if</span><span style="color: rgb(242, 242, 242);"> datasette.root_enabled </span><span style="color: rgb(64, 11, 217);">and</span><span style="color: rgb(242, 242, 242);"> actor </span><span style="color: rgb(64, 11, 217);">and</span><span style="color: rgb(242, 242, 242);"> actor.get(</span><span style="color: rgb(180, 36, 25);">"id"</span><span style="color: rgb(242, 242, 242);">) == </span><span style="color: rgb(180, 36, 25);">"root"</span><span style="color: rgb(242, 242, 242);">:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(47, 180, 29);"># Return SQL that grants access to ALL resources for this action</span>
<span style="color: rgb(242, 242, 242);"> action_obj = datasette.actions.get(action)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">if</span><span style="color: rgb(242, 242, 242);"> action_obj </span><span style="color: rgb(64, 11, 217);">and</span><span style="color: rgb(242, 242, 242);"> action_obj.resource_class:</span>
<span style="color: rgb(242, 242, 242);"> resources_sql = action_obj.resource_class.resources_sql()</span>
<span style="color: rgb(242, 242, 242);"> sql = </span><span style="color: rgb(180, 36, 25);">f"""</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);"> SELECT parent, child, 1 AS allow, 'root user' AS reason</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);"> FROM (</span><span style="color: rgb(180, 36, 25);">{resources_sql</span><span style="color: rgb(180, 36, 25);">})</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);"> """</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">return</span><span style="color: rgb(242, 242, 242);"> PermissionSQL(</span>
<span style="color: rgb(242, 242, 242);"> source=</span><span style="color: rgb(180, 36, 25);">"root_permissions"</span><span style="color: rgb(242, 242, 242);">,</span>
<span style="color: rgb(242, 242, 242);"> sql=sql,</span>
<span style="color: rgb(242, 242, 242);"> params=</span><span style="color: rgb(242, 242, 242);">{</span><span style="color: rgb(242, 242, 242);">},</span>
<span style="color: rgb(242, 242, 242);"> )</span>
<span style="color: rgb(242, 242, 242);"> rules: </span><span style="color: rgb(46, 174, 187);">list</span><span style="color: rgb(242, 242, 242);">[PermissionSQL] = []</span>
<span style="color: rgb(242, 242, 242);"> config_rules = </span><span style="color: rgb(64, 11, 217);">await</span><span style="color: rgb(242, 242, 242);"> _config_permission_rules(datasette, actor, action) </span><span style="color: rgb(47, 180, 29);"># Line 224</span>
<span style="color: rgb(242, 242, 242);"> rules.extend(config_rules)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">The bug</span><span style="color: rgb(242, 242, 242);">: When the actor is the root user, the function </span><span style="color: rgb(255, 255, 255);">returns immediately</span><span style="color: rgb(242, 242, 242);"> at line 220 with a blanket "allow all" rule.</span>
<span style="color: rgb(242, 242, 242);"> It never reaches line 224 where </span><span style="color: rgb(162, 168, 247);">_config_permission_rules()</span><span style="color: rgb(242, 242, 242);"> would read the settings-based deny rules (</span><span style="color: rgb(162, 168, 247);">-s </span>
<span style="color: rgb(242, 242, 242);"> databases.content.allow false).</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">Why It Works Differently for Direct Access</span>
<span style="color: rgb(242, 242, 242);"> The OLD </span><span style="color: rgb(162, 168, 247);">permission_allowed()</span><span style="color: rgb(242, 242, 242);"> system (used when actually accessing </span><span style="color: rgb(162, 168, 247);">/content/repos</span><span style="color: rgb(242, 242, 242);">) works correctly because:</span>
<span style="color: rgb(242, 242, 242);"> 1. At line 188-201, there's </span><span style="color: rgb(162, 168, 247);">permission_allowed_root()</span><span style="color: rgb(242, 242, 242);"> that grants root all permissions</span>
<span style="color: rgb(242, 242, 242);"> 2. But at line 130-170, there's </span><span style="color: rgb(162, 168, 247);">permission_allowed_sql_bridge()</span><span style="color: rgb(242, 242, 242);"> with </span><span style="color: rgb(162, 168, 247);">@hookimpl(tryfirst=True)</span>
<span style="color: rgb(242, 242, 242);"> 3. The </span><span style="color: rgb(162, 168, 247);">tryfirst=True</span><span style="color: rgb(242, 242, 242);"> means config rules are checked BEFORE the root check</span>
<span style="color: rgb(242, 242, 242);"> 4. So config deny rules can override root privileges in the old system</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">The Fix</span>
<span style="color: rgb(242, 242, 242);"> The </span><span style="color: rgb(162, 168, 247);">permission_resources_sql</span><span style="color: rgb(242, 242, 242);"> hook should </span><span style="color: rgb(255, 255, 255);">NOT return early</span><span style="color: rgb(242, 242, 242);"> for root users. Instead, it should:</span>
<span style="color: rgb(242, 242, 242);"> 1. Add the root "allow all" rule to the </span><span style="color: rgb(162, 168, 247);">rules</span><span style="color: rgb(242, 242, 242);"> list</span>
<span style="color: rgb(242, 242, 242);"> 2. Then continue to add config rules from settings</span>
<span style="color: rgb(242, 242, 242);"> 3. Return all rules together</span>
<span style="color: rgb(242, 242, 242);"> 4. The SQL cascading logic will properly apply: </span><span style="color: rgb(255, 255, 255);">more specific deny rules beat less specific allow rules</span>
<span style="color: rgb(242, 242, 242);"> This way:</span>
<span style="color: rgb(242, 242, 242);"> - Root gets a global-level allow (</span><span style="color: rgb(162, 168, 247);">parent=NULL, child=NULL</span><span style="color: rgb(242, 242, 242);">)</span>
<span style="color: rgb(242, 242, 242);"> - Settings provide a database-level deny (</span><span style="color: rgb(162, 168, 247);">parent='content', child=NULL</span><span style="color: rgb(242, 242, 242);">)</span>
<span style="color: rgb(242, 242, 242);"> - The database-level deny (depth=1) beats the global allow (depth=0)</span>
<span style="color: rgb(242, 242, 242);"> The fix would be to change lines 206-220 to append rather than return:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">if</span><span style="color: rgb(242, 242, 242);"> datasette.root_enabled </span><span style="color: rgb(64, 11, 217);">and</span><span style="color: rgb(242, 242, 242);"> actor </span><span style="color: rgb(64, 11, 217);">and</span><span style="color: rgb(242, 242, 242);"> actor.get(</span><span style="color: rgb(180, 36, 25);">"id"</span><span style="color: rgb(242, 242, 242);">) == </span><span style="color: rgb(180, 36, 25);">"root"</span><span style="color: rgb(242, 242, 242);">:</span>
<span style="color: rgb(242, 242, 242);"> action_obj = datasette.actions.get(action)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">if</span><span style="color: rgb(242, 242, 242);"> action_obj </span><span style="color: rgb(64, 11, 217);">and</span><span style="color: rgb(242, 242, 242);"> action_obj.resource_class:</span>
<span style="color: rgb(242, 242, 242);"> resources_sql = action_obj.resource_class.resources_sql()</span>
<span style="color: rgb(242, 242, 242);"> sql = </span><span style="color: rgb(180, 36, 25);">f"""</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);"> SELECT parent, child, 1 AS allow, 'root user' AS reason</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);"> FROM (</span><span style="color: rgb(180, 36, 25);">{resources_sql</span><span style="color: rgb(180, 36, 25);">})</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);"> """</span>
<span style="color: rgb(242, 242, 242);"> rules.append(PermissionSQL(</span>
<span style="color: rgb(242, 242, 242);"> source=</span><span style="color: rgb(180, 36, 25);">"root_permissions"</span><span style="color: rgb(242, 242, 242);">,</span>
<span style="color: rgb(242, 242, 242);"> sql=sql,</span>
<span style="color: rgb(242, 242, 242);"> params=</span><span style="color: rgb(242, 242, 242);">{</span><span style="color: rgb(242, 242, 242);">},</span>
<span style="color: rgb(242, 242, 242);"> ))</span>
<span style="color: rgb(242, 242, 242);"> Then let the function continue to line 224 to add config rules.</span>
<span style="color: rgb(255, 255, 255); background: rgb(42, 42, 42);">&gt; Add a test for this, run it and watch it fail, then fix the issue and run the test again and commit just the files you </span>
<span style="color: rgb(255, 255, 255); background: rgb(42, 42, 42);">touched </span>
<span style="color: rgb(242, 242, 242);"> Let me first check what permission endpoint tests look like:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "def test.*allowed.*root|test.*root.*permission", path: "tests/test_permission_endpoints.py", output_mode:</span>
<span style="color: rgb(242, 242, 242);"> "content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">0 </span><span style="color: rgb(242, 242, 242);">lines </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Read</span><span style="color: rgb(242, 242, 242);">(tests/test_permission_endpoints.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Read </span><span style="color: rgb(255, 255, 255);">80</span><span style="color: rgb(242, 242, 242);"> lines</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Search</span><span style="color: rgb(242, 242, 242);">(pattern: "def test.*allowed.*json|async def test_allowed", path: "tests/test_permission_endpoints.py", output_mode:</span>
<span style="color: rgb(242, 242, 242);"> "content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Found </span><span style="color: rgb(255, 255, 255);">158 </span><span style="color: rgb(242, 242, 242);">lines </span><span style="color: rgb(97, 97, 97);">(ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Now let me add a test that reproduces issue #2509:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Read</span><span style="color: rgb(242, 242, 242);">(tests/test_permission_endpoints.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Read </span><span style="color: rgb(255, 255, 255);">50</span><span style="color: rgb(242, 242, 242);"> lines</span>
<span style="color: rgb(242, 242, 242);"> Let me find the end of the file to add the test:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(wc -l /Users/simon/Dropbox/Development/datasette/tests/test_permission_endpoints.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);"> 496 /Users/simon/Dropbox/Development/datasette/tests/test_permission_endpoints.py</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Read</span><span style="color: rgb(242, 242, 242);">(tests/test_permission_endpoints.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Read </span><span style="color: rgb(255, 255, 255);">18</span><span style="color: rgb(242, 242, 242);"> lines</span>
<span style="color: rgb(242, 242, 242);"> Now let me add a test at the end of the file that reproduces the issue:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Update</span><span style="color: rgb(242, 242, 242);">(tests/test_permission_endpoints.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Updated </span><span style="color: rgb(255, 255, 255);">tests/test_permission_endpoints.py</span><span style="color: rgb(242, 242, 242);"> with </span><span style="color: rgb(255, 255, 255);">38</span><span style="color: rgb(242, 242, 242);"> additions </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 494 </span><span style="color: rgb(255, 255, 255);"> # Check for HTML structure</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 495 </span><span style="color: rgb(255, 255, 255);"> text = response.text</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 496 </span><span style="color: rgb(255, 255, 255);"> assert "&lt;!DOCTYPE html&gt;" in text or "&lt;html" in text</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 497 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 498 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 499 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ @pytest.mark.asyncio</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 500 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ async def test_root_user_respects_settings_deny():</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 501 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ """</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 502 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ Test for issue #2509: Settings-based deny rules should override root user privileges.</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 503 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 504 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ When a database has `allow: false` in settings, the root user should NOT see</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 505 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ that database in /-/allowed.json?action=view-database, even though root normally</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 506 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ has all permissions.</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 507 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ """</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 508 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ ds = Datasette(</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 509 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ config=</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 510 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ "databases": </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 511 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ "content": </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 512 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ "allow": False, # Deny everyone, including root</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 513 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">}</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 514 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">}</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 515 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">}</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 516 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ )</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 517 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ ds.root_enabled = True</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 518 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ await ds.invoke_startup()</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 519 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ ds.add_memory_database("content")</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 520 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 521 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ # Root user should NOT see the content database because settings deny it</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 522 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ response = await ds.client.get(</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 523 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ "/-/allowed.json?action=view-database",</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 524 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ cookies=</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{"ds_actor": ds.client.actor_cookie(</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{"id": "root"</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">})</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">},</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 525 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ )</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 526 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ assert response.status_code == 200</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 527 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ data = response.json()</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 528 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 529 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ # Check that content database is NOT in the allowed list</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 530 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ allowed_databases = [item["parent"] for item in data["items"]]</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 531 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ assert "content" not in allowed_databases, (</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 532 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ f"Root user should not see 'content' database when settings deny it, "</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 533 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ f"but found it in: </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{allowed_databases</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">}"</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 534 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ )</span>
<span style="color: rgb(242, 242, 242);"> Now let's run the test and watch it fail:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(python3.12-venv/bin/pytest tests/test_permission_endpoints.py::test_root_user_respects_settings_deny -xvs)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(252, 81, 109);">Error: ============================= test session starts ==============================</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">platform darwin -- Python 3.12.0, pytest-7.4.4, pluggy-1.4.0 -- </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">/Users/simon/Dropbox/Development/datasette/python3.12-venv/bin/python3.12</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">cachedir: .pytest_cache</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">SQLite: 3.42.0</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">rootdir: /Users/simon/Dropbox/Development/datasette</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">configfile: pytest.ini</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">plugins: timeout-2.2.0, asyncio-0.23.4, xdist-3.5.0, anyio-4.2.0</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">asyncio: mode=Mode.STRICT</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(252, 81, 109);">collecting ... collected 1 item</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +55 lines (</span><span style="color: rgb(102, 102, 102);">ctrl+o</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">to see all)</span>
<span style="color: rgb(242, 242, 242);"> Perfect! The test fails exactly as expected. The root user is seeing the 'content' database even though settings deny</span>
<span style="color: rgb(242, 242, 242);"> it. Now let's fix the bug:</span>
<span style="color: rgb(242, 242, 242);"> Now let's fix the bug in default_permissions.py:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Update</span><span style="color: rgb(242, 242, 242);">(datasette/default_permissions.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">Updated </span><span style="color: rgb(255, 255, 255);">datasette/default_permissions.py</span><span style="color: rgb(242, 242, 242);"> with </span><span style="color: rgb(255, 255, 255);">11</span><span style="color: rgb(242, 242, 242);"> additions and </span><span style="color: rgb(255, 255, 255);">8</span><span style="color: rgb(242, 242, 242);"> removals </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 203 </span><span style="color: rgb(255, 255, 255);"> </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 204 </span><span style="color: rgb(255, 255, 255);"> @hookimpl</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 205 </span><span style="color: rgb(255, 255, 255);"> async def permission_resources_sql(datasette, actor, action):</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 206 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- # Root user with root_enabled gets all permissions</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 206 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ rules: list[PermissionSQL] = []</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 207 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 208 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ # Root user with root_enabled gets all permissions at global level</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 209 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ # But config rules can still override at more specific levels (database/table)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 210 </span><span style="color: rgb(255, 255, 255);"> if datasette.root_enabled and actor and actor.get("id") == "root":</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 211 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- # Return SQL that grants access to ALL resources for this action</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 211 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ # Add a global-level allow rule for root</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 212 </span><span style="color: rgb(255, 255, 255);"> action_obj = datasette.actions.get(action)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 213 </span><span style="color: rgb(255, 255, 255);"> if action_obj and action_obj.resource_class:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 214 </span><span style="color: rgb(255, 255, 255);"> resources_sql = action_obj.resource_class.resources_sql()</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">...</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 213 </span><span style="color: rgb(255, 255, 255);"> SELECT parent, child, 1 AS allow, 'root user' AS reason</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 214 </span><span style="color: rgb(255, 255, 255);"> FROM (</span><span style="color: rgb(255, 255, 255);">{resources_sql</span><span style="color: rgb(255, 255, 255);">})</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 215 </span><span style="color: rgb(255, 255, 255);"> """</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 216 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- return PermissionSQL(</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 217 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- source="root_permissions",</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 218 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- sql=sql,</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 219 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- params=</span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">{</span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">},</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 216 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ rules.append(</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 217 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ PermissionSQL(</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 218 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ source="root_permissions",</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 219 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ sql=sql,</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 220 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ params=</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">{</span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">},</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 221 </span><span style="color: rgb(255, 255, 255); background: rgb(28, 75, 32);">+ )</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 222 </span><span style="color: rgb(255, 255, 255);"> )</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 223 </span><span style="color: rgb(255, 255, 255);"> </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 224 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- rules: list[PermissionSQL] = []</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 225 </span><span style="color: rgb(255, 255, 255); background: rgb(101, 28, 41);">- </span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 224 </span><span style="color: rgb(255, 255, 255);"> config_rules = await _config_permission_rules(datasette, actor, action)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 225 </span><span style="color: rgb(255, 255, 255);"> rules.extend(config_rules)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);"> 226 </span><span style="color: rgb(255, 255, 255);"> </span>
<span style="color: rgb(242, 242, 242);"> Wait, I realize there's an issue with my fix. The root user rule is providing rules for ALL resources (databases,</span>
<span style="color: rgb(242, 242, 242);"> tables), which would be at child or parent level, not global level. The issue is more subtle. Let me re-read the SQL</span>
<span style="color: rgb(242, 242, 242);"> being generated:</span>
<span style="color: rgb(242, 242, 242);"> The root user SQL is:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">SELECT</span><span style="color: rgb(242, 242, 242);"> parent, child, </span><span style="color: rgb(47, 180, 29);">1</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> allow, </span><span style="color: rgb(180, 36, 25);">'root user'</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> reason</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">FROM</span><span style="color: rgb(242, 242, 242);"> (</span><span style="color: rgb(64, 11, 217);">SELECT</span><span style="color: rgb(242, 242, 242);"> database_name </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> parent, </span><span style="color: rgb(64, 11, 217);">NULL</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> child </span><span style="color: rgb(64, 11, 217);">FROM</span><span style="color: rgb(242, 242, 242);"> catalog_databases)</span>
<span style="color: rgb(242, 242, 242);"> This returns rows like </span><span style="color: rgb(162, 168, 247);">('content', NULL, 1, 'root user')</span><span style="color: rgb(242, 242, 242);"> - which is a </span><span style="color: rgb(255, 255, 255);">parent-level</span><span style="color: rgb(242, 242, 242);"> rule (depth=1).</span>
<span style="color: rgb(242, 242, 242);"> The config deny rule for </span><span style="color: rgb(162, 168, 247);">databases.content.allow: false</span><span style="color: rgb(242, 242, 242);"> is:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">SELECT</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(180, 36, 25);">'content'</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> parent, </span><span style="color: rgb(64, 11, 217);">NULL</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> child, </span><span style="color: rgb(47, 180, 29);">0</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> allow, </span><span style="color: rgb(180, 36, 25);">'config deny...'</span><span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(64, 11, 217);">AS</span><span style="color: rgb(242, 242, 242);"> reason</span>
<span style="color: rgb(242, 242, 242);"> This is also a </span><span style="color: rgb(255, 255, 255);">parent-level</span><span style="color: rgb(242, 242, 242);"> rule (depth=1).</span>
<span style="color: rgb(242, 242, 242);"> According to the cascading logic: "Within the same depth: deny (0) beats allow (1)"</span>
<span style="color: rgb(242, 242, 242);"> So both rules are at depth=1, and deny should win! But the issue is that the root rule is returning EARLY and never</span>
<span style="color: rgb(242, 242, 242);"> adding the config rules.</span>
<span style="color: rgb(242, 242, 242);"> My fix should work - let me run the test:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(python3.12-venv/bin/pytest tests/test_permission_endpoints.py::test_root_user_respects_settings_deny -xvs)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> ============================= test session starts ============================== </span>
<span style="color: rgb(242, 242, 242);"> platform darwin -- Python 3.12.0, pytest-7.4.4, pluggy-1.4.0 -- /Users/simon/Dropbox/Development/datasette/python3.1</span>
<span style="color: rgb(242, 242, 242);"> 2-venv/bin/python3.12</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +19 lines (ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Excellent! The test now passes. Let me run a few more tests to make sure I didn't break anything:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(python3.12-venv/bin/pytest tests/test_permission_endpoints.py -xvs 2&gt;&amp;1 | tail -30)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> tests/test_permission_endpoints.py::test_rules_json_basic[/-/rules.json?action=view-instance-200-expected_keys0] PAS</span>
<span style="color: rgb(242, 242, 242);"> SED</span>
<span style="color: rgb(242, 242, 242);"> tests/test_permission_endpoints.py::test_rules_json_basic[/-/rules.json?action=view-database-200-expected_keys1] PAS</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +32 lines (ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Perfect! All 52 tests pass. Let me also run some general permission tests to make sure root still works correctly for</span>
<span style="color: rgb(242, 242, 242);"> non-denied resources:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(python3.12-venv/bin/pytest tests/test_permissions.py -x -k "root" 2&gt;&amp;1 | tail -30)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">============================= test session starts ==============================</span>
<span style="color: rgb(242, 242, 242);"> platform darwin -- Python 3.12.0, pytest-7.4.4, pluggy-1.4.0</span>
<span style="color: rgb(242, 242, 242);"> SQLite: 3.42.0</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +17 lines (ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Great! Now let's commit just the files we touched:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(git status --short)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);"> M datasette/default_permissions.py </span>
<span style="color: rgb(242, 242, 242);"> M tests/test_permission_endpoints.py</span>
<span style="color: rgb(242, 242, 242);"> ?? RELEASE_NOTES_DRAFT.md</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +40 lines (ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Perfect! Only the two files we modified. Let me stage and commit them:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(git add datasette/default_permissions.py tests/test_permission_endpoints.py)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(97, 97, 97);">(No content)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(git diff --staged)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py</span>
<span style="color: rgb(242, 242, 242);"> index e873361c..e2ef8ad0 100644</span>
<span style="color: rgb(242, 242, 242);"> --- a/datasette/default_permissions.py</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +83 lines (ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Perfect! The changes look good. Now let's commit:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(git commit -m "Fix #2509: Settings-based deny rules now override root user privileges</span>…<span style="color: rgb(242, 242, 242);">)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">[resource-based-permissions f5d1fe95] Fix #2509: Settings-based deny rules now override root user privileges</span>
<span style="color: rgb(242, 242, 242);"> 2 files changed, 49 insertions(+), 8 deletions(-)</span>
<span style="color: rgb(242, 242, 242);"> Perfect! Let me verify the commit was created correctly:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(git log --oneline -3)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">f5d1fe95 Fix #2509: Settings-based deny rules now override root user privileges </span>
<span style="color: rgb(242, 242, 242);"> d1ea067f Migrate homepage to use bulk allowed_resources() and fix NULL handling in SQL JOINs</span>
<span style="color: rgb(242, 242, 242);"> 3adddad6 Add parent filter and include_is_private to allowed_resources()</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Bash</span><span style="color: rgb(242, 242, 242);">(git show --stat HEAD)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(242, 242, 242);"> </span> <span style="color: rgb(242, 242, 242);">commit f5d1fe9587e807555e4d69ab4aa287a8ad8aea78</span>
<span style="color: rgb(242, 242, 242);"> Author: Simon Willison &lt;swillison@gmail.com&gt;</span>
<span style="color: rgb(242, 242, 242);"> Date: Fri Oct 24 10:56:51 2025 -0700</span>
<span style="color: rgb(242, 242, 242);"> </span>…<span style="color: rgb(97, 97, 97);"> +21 lines (ctrl+o to expand)</span>
<span style="color: rgb(242, 242, 242);"> Excellent! The fix has been successfully committed. Let me provide a summary:</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(255, 255, 255);">Summary</span>
<span style="color: rgb(242, 242, 242);"> Successfully fixed issue #2509 where settings-based deny rules weren't being respected for the root user in the</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(162, 168, 247);">/-/allowed</span><span style="color: rgb(242, 242, 242);"> and </span><span style="color: rgb(162, 168, 247);">/-/rules</span><span style="color: rgb(242, 242, 242);"> endpoints.</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">What I Did</span>
<span style="color: rgb(242, 242, 242);"> 1. </span><span style="color: rgb(255, 255, 255);">Added a test</span><span style="color: rgb(242, 242, 242);"> (</span><span style="color: rgb(162, 168, 247);">test_root_user_respects_settings_deny</span><span style="color: rgb(242, 242, 242);">) that:</span>
<span style="color: rgb(242, 242, 242);"> - Creates a Datasette instance with </span><span style="color: rgb(162, 168, 247);">databases.content.allow: false</span>
<span style="color: rgb(242, 242, 242);"> - Enables root user with </span><span style="color: rgb(162, 168, 247);">root_enabled = True</span>
<span style="color: rgb(242, 242, 242);"> - Verifies that the root user does NOT see the "content" database in </span><span style="color: rgb(162, 168, 247);">/-/allowed.json?action=view-database</span>
<span style="color: rgb(242, 242, 242);"> 2. </span><span style="color: rgb(255, 255, 255);">Ran the test and verified it failed</span><span style="color: rgb(242, 242, 242);"> - The root user was incorrectly seeing the "content" database despite the deny</span>
<span style="color: rgb(242, 242, 242);"> setting</span>
<span style="color: rgb(242, 242, 242);"> 3. </span><span style="color: rgb(255, 255, 255);">Fixed the bug</span><span style="color: rgb(242, 242, 242);"> in </span><span style="color: rgb(162, 168, 247);">datasette/default_permissions.py</span><span style="color: rgb(242, 242, 242);">:</span>
<span style="color: rgb(242, 242, 242);"> - Changed </span><span style="color: rgb(162, 168, 247);">permission_resources_sql()</span><span style="color: rgb(242, 242, 242);"> to </span><span style="color: rgb(255, 255, 255);">append</span><span style="color: rgb(242, 242, 242);"> root permissions to the rules list instead of returning early</span>
<span style="color: rgb(242, 242, 242);"> - This allows config-based deny rules to be evaluated alongside root's allow rules</span>
<span style="color: rgb(242, 242, 242);"> - The SQL cascading logic correctly applies: deny rules at the same depth beat allow rules</span>
<span style="color: rgb(242, 242, 242);"> 4. </span><span style="color: rgb(255, 255, 255);">Verified the fix</span><span style="color: rgb(242, 242, 242);"> - Test now passes, and all 52 permission endpoint tests pass</span>
<span style="color: rgb(242, 242, 242);"> 5. </span><span style="color: rgb(255, 255, 255);">Committed only the touched files</span><span style="color: rgb(242, 242, 242);">:</span>
<span style="color: rgb(242, 242, 242);"> - </span><span style="color: rgb(162, 168, 247);">datasette/default_permissions.py</span><span style="color: rgb(242, 242, 242);"> (19 lines changed)</span>
<span style="color: rgb(242, 242, 242);"> - </span><span style="color: rgb(162, 168, 247);">tests/test_permission_endpoints.py</span><span style="color: rgb(242, 242, 242);"> (38 lines added)</span>
<span style="color: rgb(242, 242, 242);"> </span><span style="color: rgb(97, 97, 97);">Why The Fix Works</span>
<span style="color: rgb(242, 242, 242);"> The bug was that root's blanket "allow all" rule was being returned immediately, preventing settings-based deny rules</span>
<span style="color: rgb(242, 242, 242);"> from ever being added to the SQL query. Now:</span>
<span style="color: rgb(242, 242, 242);"> - Root gets permission rules at the resource level (e.g., </span><span style="color: rgb(162, 168, 247);">parent='content', child=NULL</span><span style="color: rgb(242, 242, 242);"> for databases)</span>
<span style="color: rgb(242, 242, 242);"> - Settings provide deny rules at the same level (e.g., </span><span style="color: rgb(162, 168, 247);">parent='content', child=NULL, allow=0</span><span style="color: rgb(242, 242, 242);">)</span>
<span style="color: rgb(242, 242, 242);"> - The SQL cascading logic says: </span><span style="color: rgb(255, 255, 255);">at the same depth, deny beats allow</span>
<span style="color: rgb(242, 242, 242);"> - So the settings deny wins, correctly blocking access even for root</span>
</pre>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment