Move takes_child/takes_parent information from Action to Resource (#2567)

Simplified Action by moving takes_child/takes_parent logic to Resource

- Removed InstanceResource - global actions are now simply those with resource_class=None
- Resource.parent_class - Replaced parent_name: str with parent_class: type[Resource] | None for direct class references
- Simplified Action dataclass - No more redundant fields, everything is derived from the Resource class structure
- Validation - The __init_subclass__ method now checks parent_class.parent_class to enforce the 2-level hierarchy

Closes #2563
This commit is contained in:
Simon Willison 2025-11-01 11:35:08 -07:00 committed by GitHub
commit 5705ce0d95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 417 additions and 186 deletions

View file

@ -23,6 +23,10 @@ UNDOCUMENTED_PERMISSIONS = {
"this_is_allowed_async",
"this_is_denied_async",
"no_match",
# Test actions from test_hook_register_actions_with_custom_resources
"manage_documents",
"view_document_collection",
"view_document",
}
_ds_client = None

View file

@ -3,7 +3,7 @@ from datasette import hookimpl
from datasette.facets import Facet
from datasette import tracer
from datasette.permissions import Action
from datasette.resources import DatabaseResource, InstanceResource
from datasette.resources import DatabaseResource
from datasette.utils import path_with_added_args
from datasette.utils.asgi import asgi_send_json, Response
import base64
@ -461,94 +461,90 @@ def register_actions(datasette):
name="action-from-plugin",
abbr="ap",
description="New action added by a plugin",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource,
),
Action(
name="view-collection",
abbr="vc",
description="View a collection",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource,
),
# Test actions for test_hook_permission_allowed
# Test actions for test_hook_permission_allowed (global actions - no resource_class)
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
if extras_old:
for p in extras_old["permissions"]:
# Map old takes_database/takes_resource to new takes_parent/takes_child
actions.append(
Action(
name=p["name"],
abbr=p["abbr"],
description=p["description"],
takes_parent=p.get("takes_database", False),
takes_child=p.get("takes_resource", False),
resource_class=(
DatabaseResource
if p.get("takes_database")
else InstanceResource
),
# Map old takes_database/takes_resource to new global/resource_class
if p.get("takes_database"):
# Has database -> DatabaseResource
actions.append(
Action(
name=p["name"],
abbr=p["abbr"],
description=p["description"],
resource_class=DatabaseResource,
)
)
else:
# No database -> global action (no resource_class)
actions.append(
Action(
name=p["name"],
abbr=p["abbr"],
description=p["description"],
)
)
)
# Support new-style config
if extras_new:
for a in extras_new["actions"]:
# Map string resource_class to actual class
resource_class_map = {
"InstanceResource": InstanceResource,
"DatabaseResource": DatabaseResource,
}
resource_class = resource_class_map.get(
a.get("resource_class", "InstanceResource"), InstanceResource
)
actions.append(
Action(
name=a["name"],
abbr=a["abbr"],
description=a["description"],
takes_parent=a.get("takes_parent", False),
takes_child=a.get("takes_child", False),
resource_class=resource_class,
# Check if this is a global action (no resource_class specified)
if not a.get("resource_class"):
actions.append(
Action(
name=a["name"],
abbr=a["abbr"],
description=a["description"],
)
)
else:
# Map string resource_class to actual class
resource_class_map = {
"DatabaseResource": DatabaseResource,
}
resource_class = resource_class_map.get(
a.get("resource_class", "DatabaseResource"), DatabaseResource
)
actions.append(
Action(
name=a["name"],
abbr=a["abbr"],
description=a["description"],
resource_class=resource_class,
)
)
)
return actions

View file

@ -11,7 +11,8 @@ from datasette.app import Datasette
from datasette import cli, hookimpl
from datasette.filters import FilterArguments
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
from datasette.permissions import PermissionSQL
from datasette.permissions import PermissionSQL, Action
from datasette.resources import DatabaseResource
from datasette.utils.sqlite import sqlite3
from datasette.utils import StartupError, await_me_maybe
from jinja2 import ChoiceLoader, FileSystemLoader
@ -1184,9 +1185,6 @@ async def test_hook_register_actions(extra_metadata):
"name": "extra-from-metadata",
"abbr": "efm",
"description": "Extra from metadata",
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
}
]
}
@ -1202,8 +1200,6 @@ async def test_hook_register_actions(extra_metadata):
name="action-from-plugin",
abbr="ap",
description="New action added by a plugin",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource,
)
if extra_metadata:
@ -1211,9 +1207,6 @@ async def test_hook_register_actions(extra_metadata):
name="extra-from-metadata",
abbr="efm",
description="Extra from metadata",
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
)
else:
assert "extra-from-metadata" not in ds.actions
@ -1237,17 +1230,11 @@ async def test_hook_register_actions_no_duplicates(duplicate):
"name": name1,
"abbr": abbr1,
"description": None,
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
},
{
"name": name2,
"abbr": abbr2,
"description": None,
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
},
]
}
@ -1272,17 +1259,11 @@ async def test_hook_register_actions_allows_identical_duplicates():
"name": "name1",
"abbr": "abbr1",
"description": None,
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
},
{
"name": "name1",
"abbr": "abbr1",
"description": None,
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
},
]
}
@ -1556,6 +1537,240 @@ async def test_hook_register_actions():
assert action.description == "View a collection"
@pytest.mark.asyncio
async def test_hook_register_actions_with_custom_resources():
"""
Test registering actions with custom Resource classes:
- A global action (no resource)
- A parent-level action (DocumentCollectionResource)
- A child-level action (DocumentResource)
"""
from datasette.permissions import Resource, Action
# Define custom Resource classes
class DocumentCollectionResource(Resource):
"""A collection of documents."""
name = "document_collection"
parent_class = None # Top-level resource
def __init__(self, collection: str):
super().__init__(parent=collection, child=None)
@classmethod
async def resources_sql(cls, datasette) -> str:
return """
SELECT 'collection1' AS parent, NULL AS child
UNION ALL
SELECT 'collection2' AS parent, NULL AS child
"""
class DocumentResource(Resource):
"""A document in a collection."""
name = "document"
parent_class = DocumentCollectionResource # Child of DocumentCollectionResource
def __init__(self, collection: str, document: str):
super().__init__(parent=collection, child=document)
@classmethod
async def resources_sql(cls, datasette) -> str:
return """
SELECT 'collection1' AS parent, 'doc1' AS child
UNION ALL
SELECT 'collection1' AS parent, 'doc2' AS child
UNION ALL
SELECT 'collection2' AS parent, 'doc3' AS child
"""
# Define a test plugin that registers these actions
class TestPlugin:
__name__ = "test_custom_resources_plugin"
@hookimpl
def register_actions(self, datasette):
return [
# Global action - no resource_class
Action(
name="manage-documents",
abbr="md",
description="Manage the document system",
),
# Parent-level action - collection only
Action(
name="view-document-collection",
abbr="vdc",
description="View a document collection",
resource_class=DocumentCollectionResource,
),
# Child-level action - collection + document
Action(
name="view-document",
abbr="vdoc",
description="View a document",
resource_class=DocumentResource,
),
]
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
from datasette.permissions import PermissionSQL
# Grant user2 access to manage-documents globally
if actor and actor.get("id") == "user2" and action == "manage-documents":
return PermissionSQL.allow(reason="user2 granted manage-documents")
# Grant user2 access to view-document-collection globally
if (
actor
and actor.get("id") == "user2"
and action == "view-document-collection"
):
return PermissionSQL.allow(
reason="user2 granted view-document-collection"
)
# Register the plugin temporarily
plugin = TestPlugin()
pm.register(plugin, name="test_custom_resources_plugin")
try:
# Create datasette instance and invoke startup
datasette = Datasette(memory=True)
await datasette.invoke_startup()
# Test global action
manage_docs = datasette.actions["manage-documents"]
assert manage_docs.name == "manage-documents"
assert manage_docs.abbr == "md"
assert manage_docs.resource_class is None
assert manage_docs.takes_parent is False
assert manage_docs.takes_child is False
# Test parent-level action
view_collection = datasette.actions["view-document-collection"]
assert view_collection.name == "view-document-collection"
assert view_collection.abbr == "vdc"
assert view_collection.resource_class is DocumentCollectionResource
assert view_collection.takes_parent is True
assert view_collection.takes_child is False
# Test child-level action
view_doc = datasette.actions["view-document"]
assert view_doc.name == "view-document"
assert view_doc.abbr == "vdoc"
assert view_doc.resource_class is DocumentResource
assert view_doc.takes_parent is True
assert view_doc.takes_child is True
# Verify the resource classes have correct hierarchy
assert DocumentCollectionResource.parent_class is None
assert DocumentResource.parent_class is DocumentCollectionResource
# Test that resources can be instantiated correctly
collection_resource = DocumentCollectionResource(collection="collection1")
assert collection_resource.parent == "collection1"
assert collection_resource.child is None
doc_resource = DocumentResource(collection="collection1", document="doc1")
assert doc_resource.parent == "collection1"
assert doc_resource.child == "doc1"
# Test permission checks with restricted actors
# Test 1: Global action - no restrictions (custom actions default to deny)
unrestricted_actor = {"id": "user1"}
allowed = await datasette.allowed(
action="manage-documents",
actor=unrestricted_actor,
)
assert allowed is False # Custom actions have no default allow
# Test 2: Global action - user2 has explicit permission via plugin hook
restricted_global = {"id": "user2", "_r": {"a": ["md"]}}
allowed = await datasette.allowed(
action="manage-documents",
actor=restricted_global,
)
assert allowed is True # Granted by plugin hook for user2
# Test 3: Global action - restricted but not in allowlist
restricted_no_access = {"id": "user3", "_r": {"a": ["vdc"]}}
allowed = await datasette.allowed(
action="manage-documents",
actor=restricted_no_access,
)
assert allowed is False # Not in allowlist
# Test 4: Collection-level action - allowed for specific collection
collection_resource = DocumentCollectionResource(collection="collection1")
restricted_collection = {"id": "user4", "_r": {"d": {"collection1": ["vdc"]}}}
allowed = await datasette.allowed(
action="view-document-collection",
resource=collection_resource,
actor=restricted_collection,
)
assert allowed is True # Allowed for collection1
# Test 5: Collection-level action - denied for different collection
collection2_resource = DocumentCollectionResource(collection="collection2")
allowed = await datasette.allowed(
action="view-document-collection",
resource=collection2_resource,
actor=restricted_collection,
)
assert allowed is False # Not allowed for collection2
# Test 6: Document-level action - allowed for specific document
doc1_resource = DocumentResource(collection="collection1", document="doc1")
restricted_document = {
"id": "user5",
"_r": {"r": {"collection1": {"doc1": ["vdoc"]}}},
}
allowed = await datasette.allowed(
action="view-document",
resource=doc1_resource,
actor=restricted_document,
)
assert allowed is True # Allowed for collection1/doc1
# Test 7: Document-level action - denied for different document
doc2_resource = DocumentResource(collection="collection1", document="doc2")
allowed = await datasette.allowed(
action="view-document",
resource=doc2_resource,
actor=restricted_document,
)
assert allowed is False # Not allowed for collection1/doc2
# Test 8: Document-level action - globally allowed
doc_resource = DocumentResource(collection="collection2", document="doc3")
restricted_all_docs = {"id": "user6", "_r": {"a": ["vdoc"]}}
allowed = await datasette.allowed(
action="view-document",
resource=doc_resource,
actor=restricted_all_docs,
)
assert allowed is True # Globally allowed for all documents
# Test 9: Verify hierarchy - collection access doesn't grant document access
collection_only_actor = {"id": "user7", "_r": {"d": {"collection1": ["vdc"]}}}
doc_resource = DocumentResource(collection="collection1", document="doc1")
allowed = await datasette.allowed(
action="view-document",
resource=doc_resource,
actor=collection_only_actor,
)
assert (
allowed is False
) # Collection permission doesn't grant document permission
finally:
# Unregister the plugin
pm.unregister(plugin)
@pytest.mark.skip(reason="TODO")
@pytest.mark.parametrize(
"metadata,config,expected_metadata,expected_config",