mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
5247856bd4
commit
6a71bde37f
14 changed files with 241 additions and 227 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ async def test_sortable_columns_metadata(ds_client):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_rows",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue