mirror of
https://github.com/simonw/datasette.git
synced 2026-06-22 00:34:35 +02:00
Adds a per-request cache for permission check results, plus wiring that resolves action permissions in bulk before plugin hooks need them: - New _permission_check_cache contextvar, set to a fresh dict for each request by DatasetteRouter and reset when the request ends. Keys include the full serialized actor, so actors differing in any field (e.g. token restrictions) never share entries. SkipPermissions mode bypasses the cache entirely. - datasette.allowed_many() now consults the cache and stores its results there, so repeated datasette.allowed() checks within one request resolve without further SQL. - Table pages resolve all registered table-level actions against the current table and all database-level actions against its database (database pages likewise) in batched queries before invoking the table_actions/database_actions plugin hooks - allowed() calls made inside those hooks are then served from the cache with no plugin changes required. Actions with no permission rules from any plugin are resolved to False without touching the database. Benchmarks (benchmarks/) with a simulated 12-plugin ecosystem making 18 checks per table page show 34 -> 13 internal-DB queries per page; with 2ms-per-query internal DB latency (modelling Datasette Cloud) table page time drops from 77.9ms to 27.6ms - the caching layer accounts for ~91% of that improvement over allowed_many() alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
227 lines
7.3 KiB
Python
227 lines
7.3 KiB
Python
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from typing import Any, NamedTuple
|
|
import contextvars
|
|
|
|
# Context variable to track when permission checks should be skipped
|
|
_skip_permission_checks = contextvars.ContextVar(
|
|
"skip_permission_checks", default=False
|
|
)
|
|
|
|
# Request-scoped cache of permission check results. The ASGI router sets
|
|
# this to a fresh dict at the start of each request, so cached verdicts
|
|
# never outlive a request or leak between actors. Keys are
|
|
# (actor_json, action, parent, child) tuples, values are booleans.
|
|
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
|
|
"permission_check_cache", default=None
|
|
)
|
|
|
|
|
|
class SkipPermissions:
|
|
"""Context manager to temporarily skip permission checks.
|
|
|
|
This is not a stable API and may change in future releases.
|
|
|
|
Usage:
|
|
with SkipPermissions():
|
|
# Permission checks are skipped within this block
|
|
response = await datasette.client.get("/protected")
|
|
"""
|
|
|
|
def __enter__(self):
|
|
self.token = _skip_permission_checks.set(True)
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
_skip_permission_checks.reset(self.token)
|
|
return False
|
|
|
|
|
|
class Resource(ABC):
|
|
"""
|
|
Base class for all resource types.
|
|
|
|
Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).
|
|
The class itself carries metadata about the resource type.
|
|
Instances represent specific resources.
|
|
"""
|
|
|
|
# Class-level metadata (subclasses must define these)
|
|
name: str = None # e.g., "table", "database", "model"
|
|
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
|
|
|
# Instance-level optional extra attributes
|
|
reasons: list[str] | None = None
|
|
include_reasons: bool | None = None
|
|
|
|
def __init__(self, parent: str | None = None, child: str | None = None):
|
|
"""
|
|
Create a resource instance.
|
|
|
|
Args:
|
|
parent: The parent identifier (meaning depends on resource type)
|
|
child: The child identifier (meaning depends on resource type)
|
|
"""
|
|
self.parent = parent
|
|
self.child = child
|
|
self._private = None # Sentinel to track if private was set
|
|
|
|
def __str__(self) -> str:
|
|
return "/".join(
|
|
str(part) for part in (self.parent, self.child) if part is not None
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return "{}(parent={!r}, child={!r})".format(
|
|
self.__class__.__name__, self.parent, self.child
|
|
)
|
|
|
|
@property
|
|
def private(self) -> bool:
|
|
"""
|
|
Whether this resource is private (accessible to actor but not anonymous).
|
|
|
|
This property is only available on Resource objects returned from
|
|
allowed_resources() when include_is_private=True is used.
|
|
|
|
Raises:
|
|
AttributeError: If accessed without calling include_is_private=True
|
|
"""
|
|
if self._private is None:
|
|
raise AttributeError(
|
|
"The 'private' attribute is only available when using "
|
|
"allowed_resources(..., include_is_private=True)"
|
|
)
|
|
return self._private
|
|
|
|
@private.setter
|
|
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
|
|
async def resources_sql(cls, datasette, actor=None) -> str:
|
|
"""
|
|
Return SQL query that returns all resources of this type.
|
|
|
|
Must return two columns: parent, child
|
|
"""
|
|
pass
|
|
|
|
|
|
class AllowedResource(NamedTuple):
|
|
"""A resource with the reason it was allowed (for debugging)."""
|
|
|
|
resource: Resource
|
|
reason: str
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class Action:
|
|
name: str
|
|
description: str | None
|
|
abbr: str | None = None
|
|
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
|
|
|
|
|
|
@dataclass
|
|
class PermissionSQL:
|
|
"""
|
|
A plugin contributes SQL that yields:
|
|
parent TEXT NULL,
|
|
child TEXT NULL,
|
|
allow INTEGER, -- 1 allow, 0 deny
|
|
reason TEXT
|
|
|
|
For restriction-only plugins, sql can be None and only restriction_sql is provided.
|
|
"""
|
|
|
|
sql: str | None = (
|
|
None # SQL that SELECTs the 4 columns above (can be None for restriction-only)
|
|
)
|
|
params: dict[str, Any] | None = (
|
|
None # bound params for the SQL (values only; no ':' prefix)
|
|
)
|
|
source: str | None = None # System will set this to the plugin name
|
|
restriction_sql: str | None = (
|
|
None # Optional SQL that returns (parent, child) for restriction filtering
|
|
)
|
|
|
|
@classmethod
|
|
def allow(cls, reason: str, _allow: bool = True) -> "PermissionSQL":
|
|
global _reason_id
|
|
i = _reason_id
|
|
_reason_id += 1
|
|
return cls(
|
|
sql=f"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason",
|
|
params={f"reason_{i}": reason},
|
|
)
|
|
|
|
@classmethod
|
|
def deny(cls, reason: str) -> "PermissionSQL":
|
|
return cls.allow(reason=reason, _allow=False)
|
|
|
|
|
|
# This is obsolete, replaced by Action and ResourceType
|
|
@dataclass
|
|
class Permission:
|
|
name: str
|
|
abbr: str | None
|
|
description: str | None
|
|
takes_database: bool
|
|
takes_resource: bool
|
|
default: bool
|
|
# This is deliberately undocumented: it's considered an internal
|
|
# implementation detail for view-table/view-database and should
|
|
# not be used by plugins as it may change in the future.
|
|
implies_can_view: bool = False
|