mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01: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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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