datasette/tests/test_actions_sql.py
Tanbir Hossain Ramim fcb7936db0 Attribute permission rules to the plugin that returned them
gather_permission_sql_from_hooks() paired hook results with
get_hookimpls() by index. pluggy calls implementations in reverse
registration order and leaves out the ones that return None, so the
default source of a PermissionSQL could be credited to another plugin.

Call each implementation directly instead, so every result stays
paired with the implementation that produced it.

Refs #2978
2026-10-01 03:05:31 +02:00

393 lines
14 KiB
Python

"""
Tests for the new Resource-based permission system.
These tests verify:
1. The new Datasette.allowed_resources() method (with pagination)
2. The new Datasette.allowed() method
3. The include_reasons parameter for debugging
4. That SQL does the heavy lifting (no Python filtering)
"""
import pytest
import pytest_asyncio
from datasette import hookimpl
from datasette.app import Datasette
from datasette.permissions import PermissionSQL
from datasette.resources import DatabaseResource, QueryResource, TableResource
from datasette.utils.permissions import gather_permission_sql_from_hooks
def test_resource_string_representations():
assert str(DatabaseResource("content")) == "content"
assert repr(DatabaseResource("content")) == (
"DatabaseResource(parent='content', child=None)"
)
assert str(TableResource("content", "dogs")) == "content/dogs"
assert repr(TableResource("content", "dogs")) == (
"TableResource(parent='content', child='dogs')"
)
assert str(QueryResource("content", "insert-a-dog")) == "content/insert-a-dog"
# Test plugin that provides permission rules
class PermissionRulesPlugin:
def __init__(self, rules_callback):
self.rules_callback = rules_callback
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
"""Return permission rules based on the callback"""
return self.rules_callback(datasette, actor, action)
@pytest_asyncio.fixture
async def test_ds():
"""Create a test Datasette instance with sample data"""
ds = Datasette()
await ds.invoke_startup()
# Add test databases with some tables
db = ds.add_memory_database("analytics")
await db.execute_write("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)")
await db.execute_write("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)")
await db.execute_write(
"CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)"
)
db2 = ds.add_memory_database("production")
await db2.execute_write(
"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)"
)
await db2.execute_write(
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)"
)
# Refresh schemas to populate catalog_tables in internal database
await ds._refresh_schemas()
return ds
@pytest.mark.asyncio
async def test_allowed_resources_global_allow(test_ds):
"""Test allowed_resources() with a global allow rule"""
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(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
try:
# Use the new allowed_resources() method
result = await test_ds.allowed_resources("view-table", {"id": "alice"})
tables = result.resources
# Alice should see all tables
assert len(tables) == 5
assert all(isinstance(t, TableResource) for t in tables)
# Check specific tables are present
table_set = {(t.parent, t.child) for t in tables}
assert ("analytics", "events") in table_set
assert ("analytics", "users") in table_set
assert ("analytics", "sensitive") in table_set
assert ("production", "customers") in table_set
assert ("production", "orders") in table_set
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_allowed_specific_resource(test_ds):
"""Test allowed() method checks specific resource efficiently"""
def rules_callback(datasette, actor, action):
if actor and actor.get("role") == "analyst":
# Allow analytics database, deny everything else (global deny)
sql = """
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason
UNION ALL
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason
"""
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
try:
actor = {"id": "bob", "role": "analyst"}
# Check specific resources using allowed()
# This should use SQL WHERE clause, not fetch all resources
assert await test_ds.allowed(
action="view-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
assert await test_ds.allowed(
action="view-table",
resource=TableResource("analytics", "events"),
actor=actor,
)
assert not await test_ds.allowed(
action="view-table",
resource=TableResource("production", "orders"),
actor=actor,
)
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_allowed_resources_include_reasons(test_ds):
def rules_callback(datasette, actor, action):
if actor and actor.get("role") == "analyst":
sql = """
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow,
'parent: analyst access to analytics' AS reason
UNION ALL
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
'child: sensitive data denied' AS reason
"""
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
try:
# Use allowed_resources with include_reasons to get debugging info
result = await test_ds.allowed_resources(
"view-table", {"id": "bob", "role": "analyst"}, include_reasons=True
)
allowed = result.resources
# Should get analytics tables except sensitive
assert len(allowed) >= 2 # At least users and events
# Check we can access both resource and reason
for resource in allowed:
assert isinstance(resource, TableResource)
assert isinstance(resource.reasons, list)
if resource.parent == "analytics":
# Should mention parent-level reason in at least one of the reasons
reasons_text = " ".join(resource.reasons).lower()
assert "analyst access" in reasons_text
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_child_deny_overrides_parent_allow(test_ds):
"""Test that child-level DENY beats parent-level ALLOW"""
def rules_callback(datasette, actor, action):
if actor and actor.get("role") == "analyst":
sql = """
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow,
'parent: allow analytics' AS reason
UNION ALL
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
'child: deny sensitive' AS reason
"""
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
try:
actor = {"id": "bob", "role": "analyst"}
result = await test_ds.allowed_resources("view-table", actor)
tables = result.resources
# Should see analytics tables except sensitive
analytics_tables = [t for t in tables if t.parent == "analytics"]
assert len(analytics_tables) >= 2
table_names = {t.child for t in analytics_tables}
assert "users" in table_names
assert "events" in table_names
assert "sensitive" not in table_names
# Verify with allowed() method
assert await test_ds.allowed(
action="view-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
assert not await test_ds.allowed(
action="view-table",
resource=TableResource("analytics", "sensitive"),
actor=actor,
)
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_child_allow_overrides_parent_deny(test_ds):
"""Test that child-level ALLOW beats parent-level DENY"""
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "carol":
sql = """
SELECT 'production' AS parent, NULL AS child, 0 AS allow,
'parent: deny production' AS reason
UNION ALL
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow,
'child: carol can see orders' AS reason
"""
return PermissionSQL(sql=sql)
return None
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
try:
actor = {"id": "carol"}
result = await test_ds.allowed_resources("view-table", actor)
tables = result.resources
# Should only see production.orders
production_tables = [t for t in tables if t.parent == "production"]
assert len(production_tables) == 1
assert production_tables[0].child == "orders"
# Verify with allowed() method
assert await test_ds.allowed(
action="view-table",
resource=TableResource("production", "orders"),
actor=actor,
)
assert not await test_ds.allowed(
action="view-table",
resource=TableResource("production", "customers"),
actor=actor,
)
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_sql_does_filtering_not_python(test_ds):
"""
Verify that allowed() uses SQL WHERE clause, not Python filtering.
This test doesn't actually verify the SQL itself (that would require
query introspection), but it demonstrates the API contract.
"""
def rules_callback(datasette, actor, action):
# Deny everything by default, allow only analytics.users specifically
sql = """
SELECT NULL AS parent, NULL AS child, 0 AS allow,
'global deny' AS reason
UNION ALL
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow,
'specific allow' AS reason
"""
return PermissionSQL(sql=sql)
plugin = PermissionRulesPlugin(rules_callback)
test_ds.pm.register(plugin, name="test_plugin")
try:
actor = {"id": "dave"}
# allowed() should execute a targeted SQL query
# NOT fetch all resources and filter in Python
assert await test_ds.allowed(
action="view-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
assert not await test_ds.allowed(
action="view-table",
resource=TableResource("analytics", "events"),
actor=actor,
)
# allowed_resources() should also use SQL filtering
result = await test_ds.allowed_resources("view-table", actor)
tables = result.resources
assert len(tables) == 1
assert tables[0].parent == "analytics"
assert tables[0].child == "users"
finally:
test_ds.pm.unregister(plugin, name="test_plugin")
@pytest.mark.asyncio
async def test_gather_permission_sql_attributes_source_to_its_plugin(test_ds):
"""Each rule's default source must be the plugin that returned it.
pluggy calls implementations in reverse registration order and drops the
ones that return None, so pairing results with get_hookimpls() by index
attributed rules to the wrong plugin.
"""
class AlphaPlugin:
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if action == "view-table":
return PermissionSQL(
sql="select 'a' as parent, 'x' as child, 1 as allow, 'from-alpha' as reason"
)
return None
class BetaPlugin:
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if action in ("view-table", "insert-row"):
return PermissionSQL(
sql="select 'b' as parent, 'y' as child, 1 as allow, 'from-beta' as reason"
)
return None
test_ds.pm.register(AlphaPlugin(), name="alpha")
test_ds.pm.register(BetaPlugin(), name="beta")
try:
rules = await gather_permission_sql_from_hooks(
datasette=test_ds, actor=None, action="view-table"
)
sources = {rule.sql: rule.source for rule in rules}
assert (
sources[
"select 'a' as parent, 'x' as child, 1 as allow, 'from-alpha' as reason"
]
== "alpha"
)
assert (
sources[
"select 'b' as parent, 'y' as child, 1 as allow, 'from-beta' as reason"
]
== "beta"
)
# Alpha returns None here, so Beta's rule is the only result and must
# not be attributed to Alpha
rules = await gather_permission_sql_from_hooks(
datasette=test_ds, actor=None, action="insert-row"
)
assert [(rule.source, rule.sql) for rule in rules] == [
(
"beta",
"select 'b' as parent, 'y' as child, 1 as allow, 'from-beta' as reason",
)
]
finally:
test_ds.pm.unregister(name="alpha")
test_ds.pm.unregister(name="beta")