mirror of
https://github.com/simonw/datasette.git
synced 2026-06-06 09:07:00 +02:00
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:
parent
1f8995e776
commit
5705ce0d95
10 changed files with 417 additions and 186 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue