Permissions SQL API improvements (#2558)

* Neater design for PermissionSQL class, refs #2556
  - source is now automatically set to the source plugin
  - params is optional
* PermissionSQL.allow() and PermissionSQL.deny() shortcuts

Closes #2556

* Filter out temp database from attached_databases()

Refs https://github.com/simonw/datasette/issues/2557#issuecomment-3470510837
This commit is contained in:
Simon Willison 2025-10-30 15:48:46 -07:00 committed by GitHub
commit 6a71bde37f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 241 additions and 227 deletions

View file

@ -473,6 +473,39 @@ def register_actions(datasette):
takes_child=False,
resource_class=DatabaseResource,
),
# Test actions for test_hook_permission_allowed
Action(
name="this_is_allowed",
abbr=None,
description=None,
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
),
Action(
name="this_is_denied",
abbr=None,
description=None,
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
),
Action(
name="this_is_allowed_async",
abbr=None,
description=None,
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
),
Action(
name="this_is_denied_async",
abbr=None,
description=None,
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
),
]
# Support old-style config for backwards compatibility
@ -526,30 +559,27 @@ def permission_resources_sql(datasette, actor, action):
# Handle test actions used in test_hook_permission_allowed
if action == "this_is_allowed":
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'test plugin allows this_is_allowed' AS reason"
return PermissionSQL(source="my_plugin", sql=sql, params={})
return PermissionSQL.allow(reason="test plugin allows this_is_allowed")
elif action == "this_is_denied":
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'test plugin denies this_is_denied' AS reason"
return PermissionSQL(source="my_plugin", sql=sql, params={})
return PermissionSQL.deny(reason="test plugin denies this_is_denied")
elif action == "this_is_allowed_async":
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'test plugin allows this_is_allowed_async' AS reason"
return PermissionSQL(source="my_plugin", sql=sql, params={})
return PermissionSQL.allow(reason="test plugin allows this_is_allowed_async")
elif action == "this_is_denied_async":
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'test plugin denies this_is_denied_async' AS reason"
return PermissionSQL(source="my_plugin", sql=sql, params={})
return PermissionSQL.deny(reason="test plugin denies this_is_denied_async")
elif action == "view-database-download":
# Return rule based on actor's can_download permission
if actor and actor.get("can_download"):
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'actor has can_download' AS reason"
return PermissionSQL.allow(reason="actor has can_download")
else:
return None # No opinion
return PermissionSQL(source="my_plugin", sql=sql, params={})
elif action == "view-database":
# Also grant view-database if actor has can_download (needed for download to work)
if actor and actor.get("can_download"):
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'actor has can_download, grants view-database' AS reason"
return PermissionSQL(source="my_plugin", sql=sql, params={})
return None
return PermissionSQL.allow(
reason="actor has can_download, grants view-database"
)
else:
return None
elif action in (
"insert-row",
"create-table",
@ -560,7 +590,6 @@ def permission_resources_sql(datasette, actor, action):
# Special permissions for latest.datasette.io demos
actor_id = actor.get("id") if actor else None
if actor_id == "todomvc":
sql = f"SELECT NULL AS parent, NULL AS child, 1 AS allow, 'todomvc actor allowed for {action}' AS reason"
return PermissionSQL(source="my_plugin", sql=sql, params={})
return PermissionSQL.allow(reason=f"todomvc actor allowed for {action}")
return None

View file

@ -63,7 +63,7 @@ async def test_allowed_resources_global_allow(test_ds):
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "alice":
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason"
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -101,7 +101,7 @@ async def test_allowed_specific_resource(test_ds):
UNION ALL
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -145,7 +145,7 @@ async def test_allowed_resources_with_reasons(test_ds):
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
'child: sensitive data denied' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -186,7 +186,7 @@ async def test_child_deny_overrides_parent_allow(test_ds):
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
'child: deny sensitive' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -234,7 +234,7 @@ async def test_child_allow_overrides_parent_deny(test_ds):
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow,
'child: carol can see orders' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -283,7 +283,7 @@ async def test_sql_does_filtering_not_python(test_ds):
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow,
'specific allow' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
plugin = PermissionRulesPlugin(rules_callback)
pm.register(plugin, name="test_plugin")
@ -338,13 +338,15 @@ async def test_no_permission_rules_returns_correct_schema():
)
await ds._refresh_schemas()
# Temporarily block all permission_resources_sql hooks to simulate no rules
original_hook = pm.hook.permission_resources_sql
# Temporarily unregister all permission_resources_sql providers to simulate no rules
hook_caller = pm.hook.permission_resources_sql
hookimpls = hook_caller.get_hookimpls()
removed_plugins = [
(impl.plugin_name, impl.plugin) for impl in hookimpls if impl.plugin is not None
]
def empty_hook(*args, **kwargs):
return []
pm.hook.permission_resources_sql = empty_hook
for plugin_name, _ in removed_plugins:
pm.unregister(name=plugin_name)
try:
# Call build_allowed_resources_sql directly which will hit the no-rules code path
@ -366,5 +368,6 @@ async def test_no_permission_rules_returns_correct_schema():
assert len(result.rows) == 0
finally:
# Restore original hook
pm.hook.permission_resources_sql = original_hook
# Restore original plugins in the order they were removed
for plugin_name, plugin in removed_plugins:
pm.register(plugin, name=plugin_name)

View file

@ -58,7 +58,7 @@ async def test_tables_endpoint_global_access(test_ds):
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "alice":
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason"
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -98,7 +98,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
if actor and actor.get("role") == "analyst":
# Allow only analytics database
sql = "SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason"
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -145,7 +145,7 @@ async def test_tables_endpoint_table_exception(test_ds):
UNION ALL
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'carol exception' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -187,7 +187,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
UNION ALL
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -253,7 +253,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
UNION ALL
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, 'specific table 2' AS reason
"""
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
@ -291,7 +291,7 @@ async def test_tables_endpoint_empty_result(test_ds):
if actor and actor.get("id") == "blocked":
# Global deny
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason"
return PermissionSQL(source="test", sql=sql, params={})
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)

View file

@ -453,16 +453,12 @@ async def test_execute_sql_requires_view_database():
if action == "execute-sql":
# Grant execute-sql on the "secret" database
return PermissionSQL(
source="test_plugin",
sql="SELECT 'secret' AS parent, NULL AS child, 1 AS allow, 'can execute sql' AS reason",
params={},
)
elif action == "view-database":
# Deny view-database on the "secret" database
return PermissionSQL(
source="test_plugin",
sql="SELECT 'secret' AS parent, NULL AS child, 0 AS allow, 'cannot view db' AS reason",
params={},
)
return []

View file

@ -325,7 +325,11 @@ async def test_plugin_config_file(ds_client):
)
def test_hook_extra_body_script(app_client, path, expected_extra_body_script):
r = re.compile(r"<script type=\"module\">var extra_body_script = (.*?);</script>")
json_data = r.search(app_client.get(path).text).group(1)
response = app_client.get(path)
assert response.status_code == 200, response.text
match = r.search(response.text)
assert match is not None, "No extra_body_script found in HTML"
json_data = match.group(1)
actual_data = json.loads(json_data)
assert expected_extra_body_script == actual_data
@ -673,39 +677,11 @@ async def test_existing_scope_actor_respected(ds_client):
],
)
async def test_hook_permission_allowed(action, expected):
from datasette.permissions import Action
from datasette.resources import InstanceResource
class TestPlugin:
__name__ = "TestPlugin"
@hookimpl
def register_actions(self):
return [
Action(
name=name,
abbr=None,
description=None,
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
)
for name in (
"this_is_allowed",
"this_is_denied",
"this_is_allowed_async",
"this_is_denied_async",
)
]
pm.register(TestPlugin(), name="undo_register_extras")
try:
ds = Datasette(plugins_dir=PLUGINS_DIR)
await ds.invoke_startup()
actual = await ds.allowed(action=action, actor={"id": "actor"})
assert expected == actual
finally:
pm.unregister(name="undo_register_extras")
# Test actions and permission logic are defined in tests/plugins/my_plugin.py
ds = Datasette(plugins_dir=PLUGINS_DIR)
await ds.invoke_startup()
actual = await ds.allowed(action=action, actor={"id": "actor"})
assert expected == actual
@pytest.mark.asyncio

View file

@ -383,6 +383,7 @@ async def test_sortable_columns_metadata(ds_client):
@pytest.mark.asyncio
@pytest.mark.xfail
@pytest.mark.parametrize(
"path,expected_rows",
[

View file

@ -13,7 +13,6 @@ def db():
path = tempfile.mktemp(suffix="demo.db")
db = ds.add_database(Database(ds, path=path))
print(path)
return db
@ -25,7 +24,6 @@ NO_RULES_SQL = (
def plugin_allow_all_for_user(user: str) -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"allow_all",
"""
SELECT NULL AS parent, NULL AS child, 1 AS allow,
'global allow for ' || :allow_all_user || ' on ' || :allow_all_action AS reason
@ -42,7 +40,6 @@ def plugin_deny_specific_table(
) -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"deny_specific_table",
"""
SELECT :deny_specific_table_parent AS parent, :deny_specific_table_child AS child, 0 AS allow,
'deny ' || :deny_specific_table_parent || '/' || :deny_specific_table_child || ' for ' || :deny_specific_table_user || ' on ' || :deny_specific_table_action AS reason
@ -62,7 +59,6 @@ def plugin_deny_specific_table(
def plugin_org_policy_deny_parent(parent: str) -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"org_policy_parent_deny",
"""
SELECT :org_policy_parent_deny_parent AS parent, NULL AS child, 0 AS allow,
'org policy: parent ' || :org_policy_parent_deny_parent || ' denied on ' || :org_policy_parent_deny_action AS reason
@ -81,7 +77,6 @@ def plugin_allow_parent_for_user(
) -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"allow_parent",
"""
SELECT :allow_parent_parent AS parent, NULL AS child, 1 AS allow,
'allow full parent for ' || :allow_parent_user || ' on ' || :allow_parent_action AS reason
@ -102,7 +97,6 @@ def plugin_child_allow_for_user(
) -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"allow_child",
"""
SELECT :allow_child_parent AS parent, :allow_child_child AS child, 1 AS allow,
'allow child for ' || :allow_child_user || ' on ' || :allow_child_action AS reason
@ -122,7 +116,6 @@ def plugin_child_allow_for_user(
def plugin_root_deny_for_all() -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"root_deny",
"""
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :root_deny_action AS reason
""",
@ -137,7 +130,6 @@ def plugin_conflicting_same_child_rules(
) -> List[Callable[[str], PermissionSQL]]:
def allow_provider(action: str) -> PermissionSQL:
return PermissionSQL(
"conflict_child_allow",
"""
SELECT :conflict_child_allow_parent AS parent, :conflict_child_allow_child AS child, 1 AS allow,
'team grant at child for ' || :conflict_child_allow_user || ' on ' || :conflict_child_allow_action AS reason
@ -153,7 +145,6 @@ def plugin_conflicting_same_child_rules(
def deny_provider(action: str) -> PermissionSQL:
return PermissionSQL(
"conflict_child_deny",
"""
SELECT :conflict_child_deny_parent AS parent, :conflict_child_deny_child AS child, 0 AS allow,
'exception deny at child for ' || :conflict_child_deny_user || ' on ' || :conflict_child_deny_action AS reason
@ -175,16 +166,10 @@ def plugin_allow_all_for_action(
) -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
if action != allowed_action:
return PermissionSQL(
f"allow_all_{allowed_action}_noop",
NO_RULES_SQL,
{},
)
source_name = f"allow_all_{allowed_action}"
return PermissionSQL(NO_RULES_SQL)
# Sanitize parameter names by replacing hyphens with underscores
param_prefix = source_name.replace("-", "_")
param_prefix = action.replace("-", "_")
return PermissionSQL(
source_name,
f"""
SELECT NULL AS parent, NULL AS child, 1 AS allow,
'global allow for ' || :{param_prefix}_user || ' on ' || :{param_prefix}_action AS reason
@ -513,7 +498,6 @@ async def test_actor_actor_id_action_parameters_available(db):
def plugin_using_all_parameters() -> Callable[[str], PermissionSQL]:
def provider(action: str) -> PermissionSQL:
return PermissionSQL(
"test_all_params",
"""
SELECT NULL AS parent, NULL AS child, 1 AS allow,
'Actor ID: ' || COALESCE(:actor_id, 'null') ||
@ -521,8 +505,7 @@ async def test_actor_actor_id_action_parameters_available(db):
', Action: ' || :action AS reason
WHERE :actor_id = 'test_user' AND :action = 'view-table'
AND json_extract(:actor, '$.role') = 'admin'
""",
{},
"""
)
return provider
@ -567,7 +550,6 @@ async def test_multiple_plugins_with_own_parameters(db):
if action != "view-table":
return PermissionSQL("plugin_one", "SELECT NULL WHERE 0", {})
return PermissionSQL(
"plugin_one",
"""
SELECT database_name AS parent, table_name AS child,
1 AS allow, 'Plugin one used param: ' || :plugin1_param AS reason
@ -586,7 +568,6 @@ async def test_multiple_plugins_with_own_parameters(db):
if action != "view-table":
return PermissionSQL("plugin_two", "SELECT NULL WHERE 0", {})
return PermissionSQL(
"plugin_two",
"""
SELECT database_name AS parent, table_name AS child,
1 AS allow, 'Plugin two used param: ' || :plugin2_param AS reason