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
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue