datasette/datasette/permissions.py
Simon Willison bb59c61c9f Request-scoped permission check cache
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>
2026-06-12 13:11:17 -07:00

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