diff --git a/datasette/app.py b/datasette/app.py index 5a3d59eb..09936b3a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1308,7 +1308,7 @@ class Datasette: Uses SQL to check permission for a single resource without fetching all resources. This is efficient - it does NOT call allowed_resources() and check membership. - If resource is not provided, defaults to InstanceResource() for instance-level actions. + For global actions, resource should be None (or omitted). Example: from datasette.resources import TableResource @@ -1318,14 +1318,12 @@ class Datasette: actor=actor ) - # For instance-level actions, resource can be omitted: + # For global actions, resource can be omitted: can_debug = await datasette.allowed(action="permissions-debug", actor=actor) """ from datasette.utils.actions_sql import check_permission_for_resource - from datasette.resources import InstanceResource - if resource is None: - resource = InstanceResource() + # For global actions, resource remains None # Check if this action has also_requires - if so, check that action first action_obj = self.actions.get(action) @@ -1338,12 +1336,16 @@ class Datasette: ): return False + # For global actions, resource is None + parent = resource.parent if resource else None + child = resource.child if resource else None + result = await check_permission_for_resource( datasette=self, actor=actor, action=action, - parent=resource.parent, - child=resource.child, + parent=parent, + child=child, ) # Log the permission check for debugging @@ -1352,8 +1354,8 @@ class Datasette: when=datetime.datetime.now(datetime.timezone.utc).isoformat(), actor=actor, action=action, - parent=resource.parent, - child=resource.child, + parent=parent, + child=child, result=result, ) ) @@ -1607,7 +1609,9 @@ class Datasette: "description": action.description, "takes_parent": action.takes_parent, "takes_child": action.takes_child, - "resource_class": action.resource_class.__name__, + "resource_class": ( + action.resource_class.__name__ if action.resource_class else None + ), "also_requires": action.also_requires, } for action in sorted(self.actions.values(), key=lambda a: a.name) diff --git a/datasette/default_actions.py b/datasette/default_actions.py index e06e906b..87d98fac 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -1,7 +1,6 @@ from datasette import hookimpl from datasette.permissions import Action from datasette.resources import ( - InstanceResource, DatabaseResource, TableResource, QueryResource, @@ -12,122 +11,91 @@ from datasette.resources import ( def register_actions(): """Register the core Datasette actions.""" return ( - # View actions + # Global actions (no resource_class) Action( name="view-instance", abbr="vi", description="View Datasette instance", - takes_parent=False, - takes_child=False, - resource_class=InstanceResource, ), + Action( + name="permissions-debug", + abbr="pd", + description="Access permission debug tool", + ), + Action( + name="debug-menu", + abbr="dm", + description="View debug menu items", + ), + # Database-level actions (parent-level) Action( name="view-database", abbr="vd", description="View database", - takes_parent=True, - takes_child=False, resource_class=DatabaseResource, ), Action( name="view-database-download", abbr="vdd", description="Download database file", - takes_parent=True, - takes_child=False, resource_class=DatabaseResource, also_requires="view-database", ), - Action( - name="view-table", - abbr="vt", - description="View table", - takes_parent=True, - takes_child=True, - resource_class=TableResource, - ), - Action( - name="view-query", - abbr="vq", - description="View named query results", - takes_parent=True, - takes_child=True, - resource_class=QueryResource, - ), Action( name="execute-sql", abbr="es", description="Execute read-only SQL queries", - takes_parent=True, - takes_child=False, resource_class=DatabaseResource, also_requires="view-database", ), - # Debug actions Action( - name="permissions-debug", - abbr="pd", - description="Access permission debug tool", - takes_parent=False, - takes_child=False, - resource_class=InstanceResource, + name="create-table", + abbr="ct", + description="Create tables", + resource_class=DatabaseResource, ), + # Table-level actions (child-level) Action( - name="debug-menu", - abbr="dm", - description="View debug menu items", - takes_parent=False, - takes_child=False, - resource_class=InstanceResource, + name="view-table", + abbr="vt", + description="View table", + resource_class=TableResource, ), - # Write actions on tables Action( name="insert-row", abbr="ir", description="Insert rows", - takes_parent=True, - takes_child=True, resource_class=TableResource, ), Action( name="delete-row", abbr="dr", description="Delete rows", - takes_parent=True, - takes_child=True, resource_class=TableResource, ), Action( name="update-row", abbr="ur", description="Update rows", - takes_parent=True, - takes_child=True, resource_class=TableResource, ), Action( name="alter-table", abbr="at", description="Alter tables", - takes_parent=True, - takes_child=True, resource_class=TableResource, ), Action( name="drop-table", abbr="dt", description="Drop tables", - takes_parent=True, - takes_child=True, resource_class=TableResource, ), - # Schema actions on databases + # Query-level actions (child-level) Action( - name="create-table", - abbr="ct", - description="Create tables", - takes_parent=True, - takes_child=False, - resource_class=DatabaseResource, + name="view-query", + abbr="vq", + description="View named query results", + resource_class=QueryResource, ), ) diff --git a/datasette/permissions.py b/datasette/permissions.py index 0943eced..8e0d0fc1 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -14,7 +14,7 @@ class Resource(ABC): # Class-level metadata (subclasses must define these) name: str = None # e.g., "table", "database", "model" - parent_name: str | None = None # e.g., "database" for tables + parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables # Instance-level optional extra attributes reasons: list[str] | None = None @@ -54,6 +54,29 @@ class Resource(ABC): def private(self, value: bool): self._private = value + @classmethod + def __init_subclass__(cls): + """ + Validate resource hierarchy doesn't exceed 2 levels. + + Raises: + ValueError: If this resource would create a 3-level hierarchy + """ + super().__init_subclass__() + + if cls.parent_class is None: + return # Top of hierarchy, nothing to validate + + # Check if our parent has a parent - that would create 3 levels + if cls.parent_class.parent_class is not None: + # We have a parent, and that parent has a parent + # This creates a 3-level hierarchy, which is not allowed + raise ValueError( + f"Resource {cls.__name__} creates a 3-level hierarchy: " + f"{cls.parent_class.parent_class.__name__} -> {cls.parent_class.__name__} -> {cls.__name__}. " + f"Maximum 2 levels allowed (parent -> child)." + ) + @classmethod @abstractmethod def resources_sql(cls) -> str: @@ -77,11 +100,32 @@ class Action: name: str abbr: str | None description: str | None - takes_parent: bool - takes_child: bool - resource_class: type[Resource] + resource_class: type[Resource] | None = None also_requires: str | None = None # Optional action name that must also be allowed + @property + def takes_parent(self) -> bool: + """ + Whether this action requires a parent identifier when instantiating its resource. + + Returns False for global-only actions (no resource_class). + Returns True for all actions with a resource_class (all resources require a parent identifier). + """ + return self.resource_class is not None + + @property + def takes_child(self) -> bool: + """ + Whether this action requires a child identifier when instantiating its resource. + + Returns False for global actions (no resource_class). + Returns False for parent-level resources (DatabaseResource - parent_class is None). + Returns True for child-level resources (TableResource, QueryResource - have a parent_class). + """ + if self.resource_class is None: + return False + return self.resource_class.parent_class is not None + _reason_id = 1 diff --git a/datasette/resources.py b/datasette/resources.py index 847f1686..641afb2f 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -3,25 +3,11 @@ from datasette.permissions import Resource -class InstanceResource(Resource): - """The Datasette instance itself.""" - - name = "instance" - parent_name = None - - def __init__(self): - super().__init__(parent=None, child=None) - - @classmethod - async def resources_sql(cls, datasette) -> str: - return "SELECT NULL AS parent, NULL AS child" - - class DatabaseResource(Resource): """A database in Datasette.""" name = "database" - parent_name = "instance" + parent_class = None # Top of the resource hierarchy def __init__(self, database: str): super().__init__(parent=database, child=None) @@ -38,7 +24,7 @@ class TableResource(Resource): """A table in a database.""" name = "table" - parent_name = "database" + parent_class = DatabaseResource def __init__(self, database: str, table: str): super().__init__(parent=database, child=table) @@ -58,7 +44,7 @@ class QueryResource(Resource): """A canned query in a database.""" name = "query" - parent_name = "database" + parent_class = DatabaseResource def __init__(self, database: str, query: str): super().__init__(parent=database, child=query) diff --git a/datasette/views/special.py b/datasette/views/special.py index 8de83fae..5a341911 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,7 +1,7 @@ import json import logging from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent -from datasette.resources import DatabaseResource, TableResource, InstanceResource +from datasette.resources import DatabaseResource, TableResource from datasette.utils.asgi import Response, Forbidden from datasette.utils import ( actor_matches_allow, @@ -491,12 +491,18 @@ async def _check_permission_for_actor(ds, action, parent, child, actor): if not action_obj: return {"error": f"Unknown action: {action}"}, 400 - if action_obj.takes_parent and action_obj.takes_child: + # Global actions (no resource_class) don't have a resource + if action_obj.resource_class is None: + resource_obj = None + elif action_obj.takes_parent and action_obj.takes_child: + # Child-level resource (e.g., TableResource, QueryResource) resource_obj = action_obj.resource_class(database=parent, table=child) elif action_obj.takes_parent: + # Parent-level resource (e.g., DatabaseResource) resource_obj = action_obj.resource_class(database=parent) else: - resource_obj = action_obj.resource_class() + # This shouldn't happen given validation in Action.__post_init__ + return {"error": f"Invalid action configuration: {action}"}, 500 allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 0dc4bd6e..859b0c84 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -883,24 +883,18 @@ Actions define what operations can be performed on resources (like viewing a tab name="list-documents", abbr="ld", description="List documents in a collection", - takes_parent=True, - takes_child=False, resource_class=DocumentCollectionResource, ), Action( name="view-document", abbr="vdoc", description="View document", - takes_parent=True, - takes_child=True, resource_class=DocumentResource, ), Action( name="edit-document", abbr="edoc", description="Edit document", - takes_parent=True, - takes_child=True, resource_class=DocumentResource, ), ] @@ -916,26 +910,20 @@ The fields of the ``Action`` dataclass are as follows: ``description`` - string or None A human-readable description of what the action allows you to do. -``takes_parent`` - boolean - ``True`` if this action requires a parent identifier (like a database name). - -``takes_child`` - boolean - ``True`` if this action requires a child identifier (like a table or document name). - -``resource_class`` - type[Resource] - The Resource subclass that defines what kind of resource this action applies to. Your Resource subclass must: +``resource_class`` - type[Resource] or None + The Resource subclass that defines what kind of resource this action applies to. Omit this (or set to ``None``) for global actions that apply only at the instance level with no associated resources (like ``debug-menu`` or ``permissions-debug``). Your Resource subclass must: - Define a ``name`` class attribute (e.g., ``"document"``) - - Optionally define a ``parent_name`` class attribute (e.g., ``"collection"``) + - Define a ``parent_class`` class attribute (``None`` for top-level resources like databases, or the parent ``Resource`` subclass for child resources) - Implement a ``resources_sql()`` classmethod that returns SQL returning all resources as ``(parent, child)`` columns - Have an ``__init__`` method that accepts appropriate parameters and calls ``super().__init__(parent=..., child=...)`` The ``resources_sql()`` method ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``resources_sql()`` classmethod is crucial to Datasette's permission system. It returns a SQL query that lists all resources of that type that exist in the system. +The ``resources_sql()`` classmethod returns a SQL query that lists all resources of that type that exist in the system. -This SQL query is used by Datasette to efficiently check permissions across multiple resources at once. When a user requests a list of resources (like tables, documents, or other entities), Datasette uses this SQL to: +This query is used by Datasette to efficiently check permissions across multiple resources at once. When a user requests a list of resources (like tables, documents, or other entities), Datasette uses this SQL to: 1. Get all resources of this type from your data catalog 2. Combine it with permission rules from the ``permission_resources_sql`` hook diff --git a/docs/upgrade-1.0a20.md b/docs/upgrade-1.0a20.md index fcb77062..ec2b9a5a 100644 --- a/docs/upgrade-1.0a20.md +++ b/docs/upgrade-1.0a20.md @@ -41,7 +41,7 @@ def register_permissions(datasette): ), ] ``` -The new `Action` does not have a `default=` parameter, and `takes_database` and `takes_resource` have been renamed to `takes_parent` and `takes_child. The new code would look like this: +The new `Action` does not have a `default=` parameter. For global actions (those that don't apply to specific resources), omit `resource_class`: ```python from datasette.permissions import Action @@ -53,21 +53,41 @@ def register_actions(datasette): name="datasette-pins-write", abbr=None, description="Can pin, unpin, and re-order pins for datasette-pins", - takes_parent=False, - takes_child=False, - default=False, ), Action( name="datasette-pins-read", abbr=None, description="Can read pinned items.", - takes_parent=False, - takes_child=False, - default=False, ), ] ``` +For actions that apply to specific resources (like databases or tables), specify the `resource_class` instead of `takes_parent` and `takes_child`: + +```python +from datasette.permissions import Action +from datasette.resources import DatabaseResource, TableResource + +@hookimpl +def register_actions(datasette): + return [ + Action( + name="execute-sql", + abbr="es", + description="Execute SQL queries", + resource_class=DatabaseResource, # Parent-level resource + ), + Action( + name="insert-row", + abbr="ir", + description="Insert rows", + resource_class=TableResource, # Child-level resource + ), + ] +``` + +The hierarchy information (whether an action takes parent/child parameters) is now derived from the `Resource` class hierarchy. `Action` has `takes_parent` and `takes_child` properties that are computed based on the `resource_class` and its `parent_class` attribute. + ## permission_allowed() hook is replaced by permission_resources_sql() The following old code: diff --git a/tests/conftest.py b/tests/conftest.py index 4797ab71..4a8ef51d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 2cdd75b0..1435ce28 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -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 diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f1731b40..1c601b27 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -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",