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

@ -1308,7 +1308,7 @@ class Datasette:
Uses SQL to check permission for a single resource without fetching all resources. 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. 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: Example:
from datasette.resources import TableResource from datasette.resources import TableResource
@ -1318,14 +1318,12 @@ class Datasette:
actor=actor 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) can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
""" """
from datasette.utils.actions_sql import check_permission_for_resource from datasette.utils.actions_sql import check_permission_for_resource
from datasette.resources import InstanceResource
if resource is None: # For global actions, resource remains None
resource = InstanceResource()
# Check if this action has also_requires - if so, check that action first # Check if this action has also_requires - if so, check that action first
action_obj = self.actions.get(action) action_obj = self.actions.get(action)
@ -1338,12 +1336,16 @@ class Datasette:
): ):
return False 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( result = await check_permission_for_resource(
datasette=self, datasette=self,
actor=actor, actor=actor,
action=action, action=action,
parent=resource.parent, parent=parent,
child=resource.child, child=child,
) )
# Log the permission check for debugging # Log the permission check for debugging
@ -1352,8 +1354,8 @@ class Datasette:
when=datetime.datetime.now(datetime.timezone.utc).isoformat(), when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
actor=actor, actor=actor,
action=action, action=action,
parent=resource.parent, parent=parent,
child=resource.child, child=child,
result=result, result=result,
) )
) )
@ -1607,7 +1609,9 @@ class Datasette:
"description": action.description, "description": action.description,
"takes_parent": action.takes_parent, "takes_parent": action.takes_parent,
"takes_child": action.takes_child, "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, "also_requires": action.also_requires,
} }
for action in sorted(self.actions.values(), key=lambda a: a.name) for action in sorted(self.actions.values(), key=lambda a: a.name)

View file

@ -1,7 +1,6 @@
from datasette import hookimpl from datasette import hookimpl
from datasette.permissions import Action from datasette.permissions import Action
from datasette.resources import ( from datasette.resources import (
InstanceResource,
DatabaseResource, DatabaseResource,
TableResource, TableResource,
QueryResource, QueryResource,
@ -12,122 +11,91 @@ from datasette.resources import (
def register_actions(): def register_actions():
"""Register the core Datasette actions.""" """Register the core Datasette actions."""
return ( return (
# View actions # Global actions (no resource_class)
Action( Action(
name="view-instance", name="view-instance",
abbr="vi", abbr="vi",
description="View Datasette instance", 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( Action(
name="view-database", name="view-database",
abbr="vd", abbr="vd",
description="View database", description="View database",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource, resource_class=DatabaseResource,
), ),
Action( Action(
name="view-database-download", name="view-database-download",
abbr="vdd", abbr="vdd",
description="Download database file", description="Download database file",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource, resource_class=DatabaseResource,
also_requires="view-database", 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( Action(
name="execute-sql", name="execute-sql",
abbr="es", abbr="es",
description="Execute read-only SQL queries", description="Execute read-only SQL queries",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource, resource_class=DatabaseResource,
also_requires="view-database", also_requires="view-database",
), ),
# Debug actions
Action( Action(
name="permissions-debug", name="create-table",
abbr="pd", abbr="ct",
description="Access permission debug tool", description="Create tables",
takes_parent=False, resource_class=DatabaseResource,
takes_child=False,
resource_class=InstanceResource,
), ),
# Table-level actions (child-level)
Action( Action(
name="debug-menu", name="view-table",
abbr="dm", abbr="vt",
description="View debug menu items", description="View table",
takes_parent=False, resource_class=TableResource,
takes_child=False,
resource_class=InstanceResource,
), ),
# Write actions on tables
Action( Action(
name="insert-row", name="insert-row",
abbr="ir", abbr="ir",
description="Insert rows", description="Insert rows",
takes_parent=True,
takes_child=True,
resource_class=TableResource, resource_class=TableResource,
), ),
Action( Action(
name="delete-row", name="delete-row",
abbr="dr", abbr="dr",
description="Delete rows", description="Delete rows",
takes_parent=True,
takes_child=True,
resource_class=TableResource, resource_class=TableResource,
), ),
Action( Action(
name="update-row", name="update-row",
abbr="ur", abbr="ur",
description="Update rows", description="Update rows",
takes_parent=True,
takes_child=True,
resource_class=TableResource, resource_class=TableResource,
), ),
Action( Action(
name="alter-table", name="alter-table",
abbr="at", abbr="at",
description="Alter tables", description="Alter tables",
takes_parent=True,
takes_child=True,
resource_class=TableResource, resource_class=TableResource,
), ),
Action( Action(
name="drop-table", name="drop-table",
abbr="dt", abbr="dt",
description="Drop tables", description="Drop tables",
takes_parent=True,
takes_child=True,
resource_class=TableResource, resource_class=TableResource,
), ),
# Schema actions on databases # Query-level actions (child-level)
Action( Action(
name="create-table", name="view-query",
abbr="ct", abbr="vq",
description="Create tables", description="View named query results",
takes_parent=True, resource_class=QueryResource,
takes_child=False,
resource_class=DatabaseResource,
), ),
) )

View file

@ -14,7 +14,7 @@ class Resource(ABC):
# Class-level metadata (subclasses must define these) # Class-level metadata (subclasses must define these)
name: str = None # e.g., "table", "database", "model" 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 # Instance-level optional extra attributes
reasons: list[str] | None = None reasons: list[str] | None = None
@ -54,6 +54,29 @@ class Resource(ABC):
def private(self, value: bool): def private(self, value: bool):
self._private = value 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 @classmethod
@abstractmethod @abstractmethod
def resources_sql(cls) -> str: def resources_sql(cls) -> str:
@ -77,11 +100,32 @@ class Action:
name: str name: str
abbr: str | None abbr: str | None
description: str | None description: str | None
takes_parent: bool resource_class: type[Resource] | None = None
takes_child: bool
resource_class: type[Resource]
also_requires: str | None = None # Optional action name that must also be allowed 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 _reason_id = 1

View file

@ -3,25 +3,11 @@
from datasette.permissions import Resource 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): class DatabaseResource(Resource):
"""A database in Datasette.""" """A database in Datasette."""
name = "database" name = "database"
parent_name = "instance" parent_class = None # Top of the resource hierarchy
def __init__(self, database: str): def __init__(self, database: str):
super().__init__(parent=database, child=None) super().__init__(parent=database, child=None)
@ -38,7 +24,7 @@ class TableResource(Resource):
"""A table in a database.""" """A table in a database."""
name = "table" name = "table"
parent_name = "database" parent_class = DatabaseResource
def __init__(self, database: str, table: str): def __init__(self, database: str, table: str):
super().__init__(parent=database, child=table) super().__init__(parent=database, child=table)
@ -58,7 +44,7 @@ class QueryResource(Resource):
"""A canned query in a database.""" """A canned query in a database."""
name = "query" name = "query"
parent_name = "database" parent_class = DatabaseResource
def __init__(self, database: str, query: str): def __init__(self, database: str, query: str):
super().__init__(parent=database, child=query) super().__init__(parent=database, child=query)

View file

@ -1,7 +1,7 @@
import json import json
import logging import logging
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent 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.asgi import Response, Forbidden
from datasette.utils import ( from datasette.utils import (
actor_matches_allow, actor_matches_allow,
@ -491,12 +491,18 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
if not action_obj: if not action_obj:
return {"error": f"Unknown action: {action}"}, 400 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) resource_obj = action_obj.resource_class(database=parent, table=child)
elif action_obj.takes_parent: elif action_obj.takes_parent:
# Parent-level resource (e.g., DatabaseResource)
resource_obj = action_obj.resource_class(database=parent) resource_obj = action_obj.resource_class(database=parent)
else: 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) allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)

View file

@ -883,24 +883,18 @@ Actions define what operations can be performed on resources (like viewing a tab
name="list-documents", name="list-documents",
abbr="ld", abbr="ld",
description="List documents in a collection", description="List documents in a collection",
takes_parent=True,
takes_child=False,
resource_class=DocumentCollectionResource, resource_class=DocumentCollectionResource,
), ),
Action( Action(
name="view-document", name="view-document",
abbr="vdoc", abbr="vdoc",
description="View document", description="View document",
takes_parent=True,
takes_child=True,
resource_class=DocumentResource, resource_class=DocumentResource,
), ),
Action( Action(
name="edit-document", name="edit-document",
abbr="edoc", abbr="edoc",
description="Edit document", description="Edit document",
takes_parent=True,
takes_child=True,
resource_class=DocumentResource, resource_class=DocumentResource,
), ),
] ]
@ -916,26 +910,20 @@ The fields of the ``Action`` dataclass are as follows:
``description`` - string or None ``description`` - string or None
A human-readable description of what the action allows you to do. A human-readable description of what the action allows you to do.
``takes_parent`` - boolean ``resource_class`` - type[Resource] or None
``True`` if this action requires a parent identifier (like a database name). 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:
``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:
- Define a ``name`` class attribute (e.g., ``"document"``) - 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 - 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=...)`` - Have an ``__init__`` method that accepts appropriate parameters and calls ``super().__init__(parent=..., child=...)``
The ``resources_sql()`` method 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 1. Get all resources of this type from your data catalog
2. Combine it with permission rules from the ``permission_resources_sql`` hook 2. Combine it with permission rules from the ``permission_resources_sql`` hook

View file

@ -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 ```python
from datasette.permissions import Action from datasette.permissions import Action
@ -53,21 +53,41 @@ def register_actions(datasette):
name="datasette-pins-write", name="datasette-pins-write",
abbr=None, abbr=None,
description="Can pin, unpin, and re-order pins for datasette-pins", description="Can pin, unpin, and re-order pins for datasette-pins",
takes_parent=False,
takes_child=False,
default=False,
), ),
Action( Action(
name="datasette-pins-read", name="datasette-pins-read",
abbr=None, abbr=None,
description="Can read pinned items.", 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() ## permission_allowed() hook is replaced by permission_resources_sql()
The following old code: The following old code:

View file

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

View file

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

View file

@ -11,7 +11,8 @@ from datasette.app import Datasette
from datasette import cli, hookimpl from datasette import cli, hookimpl
from datasette.filters import FilterArguments from datasette.filters import FilterArguments
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm 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.sqlite import sqlite3
from datasette.utils import StartupError, await_me_maybe from datasette.utils import StartupError, await_me_maybe
from jinja2 import ChoiceLoader, FileSystemLoader from jinja2 import ChoiceLoader, FileSystemLoader
@ -1184,9 +1185,6 @@ async def test_hook_register_actions(extra_metadata):
"name": "extra-from-metadata", "name": "extra-from-metadata",
"abbr": "efm", "abbr": "efm",
"description": "Extra from metadata", "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", name="action-from-plugin",
abbr="ap", abbr="ap",
description="New action added by a plugin", description="New action added by a plugin",
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource, resource_class=DatabaseResource,
) )
if extra_metadata: if extra_metadata:
@ -1211,9 +1207,6 @@ async def test_hook_register_actions(extra_metadata):
name="extra-from-metadata", name="extra-from-metadata",
abbr="efm", abbr="efm",
description="Extra from metadata", description="Extra from metadata",
takes_parent=False,
takes_child=False,
resource_class=InstanceResource,
) )
else: else:
assert "extra-from-metadata" not in ds.actions assert "extra-from-metadata" not in ds.actions
@ -1237,17 +1230,11 @@ async def test_hook_register_actions_no_duplicates(duplicate):
"name": name1, "name": name1,
"abbr": abbr1, "abbr": abbr1,
"description": None, "description": None,
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
}, },
{ {
"name": name2, "name": name2,
"abbr": abbr2, "abbr": abbr2,
"description": None, "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", "name": "name1",
"abbr": "abbr1", "abbr": "abbr1",
"description": None, "description": None,
"takes_parent": False,
"takes_child": False,
"resource_class": "InstanceResource",
}, },
{ {
"name": "name1", "name": "name1",
"abbr": "abbr1", "abbr": "abbr1",
"description": None, "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" 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.skip(reason="TODO")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"metadata,config,expected_metadata,expected_config", "metadata,config,expected_metadata,expected_config",