datasette.allowed_many() method

This commit is contained in:
Simon Willison 2026-06-12 12:51:40 -07:00
commit 88878b4184
6 changed files with 614 additions and 101 deletions

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
import contextvars
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
if TYPE_CHECKING:
from datasette.permissions import Resource
@ -1817,46 +1817,97 @@ class Datasette:
# For global actions, resource can be omitted:
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
"""
from datasette.utils.actions_sql import check_permission_for_resource
results = await self.allowed_many(
actions=[action], resource=resource, actor=actor
)
return results[action]
# For global actions, resource remains None
async def allowed_many(
self,
*,
actions: Sequence[str],
resource: "Resource" = None,
actor: dict | None = None,
) -> dict[str, bool]:
"""
Check several actions against one resource for one actor.
# Check if this action has also_requires - if so, check that action first
action_obj = self.actions.get(action)
if action_obj and action_obj.also_requires:
# Must have the required action first
if not await self.allowed(
action=action_obj.also_requires,
resource=resource,
Resolves every action (plus any also_requires dependencies) with a
single internal database query, instead of one or two queries per
action.
Example:
from datasette.resources import TableResource
results = await datasette.allowed_many(
actions=["edit-schema", "drop-table", "insert-row"],
resource=TableResource(database="data", table="exercise"),
actor=actor,
):
return False
)
# {"edit-schema": True, "drop-table": True, "insert-row": False}
"""
from datasette.utils.actions_sql import check_permissions_for_actions
# 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(
# Expand also_requires dependencies (transitively) so that each
# dependency is resolved within the same batch
expanded = []
def add_action(name):
if name in expanded:
return
action_obj = self.actions.get(name)
if action_obj is None:
raise ValueError(f"Unknown action: {name}")
expanded.append(name)
if action_obj.also_requires:
add_action(action_obj.also_requires)
requested = list(dict.fromkeys(actions))
for name in requested:
add_action(name)
raw = await check_permissions_for_actions(
datasette=self,
actor=actor,
action=action,
actions=expanded,
parent=parent,
child=child,
)
final = {}
# Log the permission check for debugging
self._permission_checks.append(
PermissionCheck(
when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
actor=actor,
action=action,
parent=parent,
child=child,
result=result,
def resolve(name):
# final verdict = own rules AND verdict of also_requires chain
if name in final:
return final[name]
result = raw[name]
action_obj = self.actions.get(name)
if result and action_obj.also_requires:
result = resolve(action_obj.also_requires)
final[name] = result
return result
for name in expanded:
resolve(name)
# Log every check for the debug page, dependencies before the
# actions that required them
when = datetime.datetime.now(datetime.timezone.utc).isoformat()
for name in reversed(expanded):
self._permission_checks.append(
PermissionCheck(
when=when,
actor=actor,
action=name,
parent=parent,
child=child,
result=final[name],
)
)
)
return result
return {name: final[name] for name in requested}
async def ensure_permission(
self,

View file

@ -21,6 +21,8 @@ The core pattern is:
- Across levels, child beats parent beats global
"""
import asyncio
import re
from typing import TYPE_CHECKING
from datasette.utils.permissions import gather_permission_sql_from_hooks
@ -495,6 +497,146 @@ async def build_permission_rules_sql(
return rules_union, all_params, restriction_sqls
async def check_permissions_for_actions(
*,
datasette: "Datasette",
actor: dict | None,
actions: list[str],
parent: str | None,
child: str | None,
) -> dict[str, bool]:
"""
Check several actions for one actor and resource in a single query.
Args:
datasette: The Datasette instance
actor: The actor dict (or None)
actions: List of action names to check
parent: The parent resource identifier (e.g., database name, or None)
child: The child resource identifier (e.g., table name, or None)
Returns:
Dict mapping each action name to True (allowed) or False (denied)
Each action contributes its own tagged block of permission rules
(gathered from the permission_resources_sql hook, with parameters
namespaced per action to avoid collisions) plus an optional
restriction allowlist CTE. One internal database query resolves
the winning rule per action using the same specificity-then-deny
ordering as the rest of the permission system.
Note: this resolves each action independently - also_requires
dependencies are handled by the caller (Datasette.allowed_many).
"""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
for action in actions:
if not datasette.actions.get(action):
raise ValueError(f"Unknown action: {action}")
# Dedupe while preserving order
unique_actions = list(dict.fromkeys(actions))
if not unique_actions:
return {}
# Gather hook results for each action concurrently - hooks within a
# single action still run sequentially, preserving existing semantics
gathered = await asyncio.gather(
*(
gather_permission_sql_from_hooks(
datasette=datasette, actor=actor, action=action
)
for action in unique_actions
)
)
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
return {action: True for action in unique_actions}
params = {"_check_parent": parent, "_check_child": child}
ctes = []
selects = []
verdicts = {}
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
prefix = f"a{i}_"
rule_parts = []
restriction_parts = []
for permission_sql in permission_sqls:
sql = permission_sql.sql
restriction_sql = permission_sql.restriction_sql
# Namespace this block's params so identical names used for
# different actions cannot collide
for key in permission_sql.params or {}:
new_key = prefix + key
params[new_key] = permission_sql.params[key]
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
if sql:
sql = pattern.sub(":" + new_key, sql)
if restriction_sql:
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
if restriction_sql:
restriction_parts.append(restriction_sql)
# Skip plugins that only provide restriction_sql (no permission rules)
if sql is None:
continue
rule_parts.append(
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
)
if not rule_parts:
# No rules from any plugin - default deny. Restrictions can
# only restrict, never grant, so no SQL is needed at all
verdicts[action] = False
continue
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
# Winning rule for this action: most specific depth first, then
# deny-beats-allow, then source_plugin as a stable tie-break
verdict_sql = f"""COALESCE((
SELECT allow FROM (
SELECT allow, source_plugin,
CASE
WHEN child IS NOT NULL THEN 2
WHEN parent IS NOT NULL THEN 1
ELSE 0
END AS depth
FROM a{i}_rules
WHERE (parent IS NULL OR parent = :_check_parent)
AND (child IS NULL OR child = :_check_child)
ORDER BY
depth DESC,
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
source_plugin
LIMIT 1
)
), 0)"""
if restriction_parts:
# Database-level restrictions (parent, NULL) match all children
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_parts
)
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
verdict_sql = f"""({verdict_sql}) AND EXISTS (
SELECT 1 FROM a{i}_restriction r
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
AND (r.child = :_check_child OR r.child IS NULL)
)"""
selects.append(f"SELECT {i} AS action_idx, ({verdict_sql}) AS is_allowed")
if selects:
query = "WITH\n" + ",\n".join(ctes) + "\n" + "\nUNION ALL\n".join(selects)
result = await datasette.get_internal_database().execute(query, params)
for row in result.rows:
verdicts[unique_actions[row[0]]] = bool(row[1])
return verdicts
async def check_permission_for_resource(
*,
datasette: "Datasette",
@ -515,77 +657,12 @@ async def check_permission_for_resource(
Returns:
True if the actor is allowed, False otherwise
This builds the cascading permission query and checks if the specific
resource is in the allowed set.
"""
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
datasette, actor, action
results = await check_permissions_for_actions(
datasette=datasette,
actor=actor,
actions=[action],
parent=parent,
child=child,
)
# If no rules (empty SQL), default deny
if not rules_union:
return False
# Add parameters for the resource we're checking
all_params["_check_parent"] = parent
all_params["_check_child"] = child
# If there are restriction filters, check if the resource passes them first
if restriction_sqls:
# Check if resource is in restriction allowlist
# Database-level restrictions (parent, NULL) should match all children (parent, *)
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
restriction_check = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls
)
restriction_query = f"""
WITH restriction_list AS (
{restriction_check}
)
SELECT EXISTS (
SELECT 1 FROM restriction_list
WHERE (parent = :_check_parent OR parent IS NULL)
AND (child = :_check_child OR child IS NULL)
) AS in_allowlist
"""
result = await datasette.get_internal_database().execute(
restriction_query, all_params
)
if result.rows and not result.rows[0][0]:
# Resource not in restriction allowlist - deny
return False
query = f"""
WITH
all_rules AS (
{rules_union}
),
matched_rules AS (
SELECT ar.*,
CASE
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
ELSE 0 -- root/global
END AS depth
FROM all_rules ar
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
AND (ar.child IS NULL OR ar.child = :_check_child)
),
winner AS (
SELECT *
FROM matched_rules
ORDER BY
depth DESC, -- specificity first (higher depth wins)
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
source_plugin -- stable tie-break
LIMIT 1
)
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
"""
# Execute the query against the internal database
result = await datasette.get_internal_database().execute(query, all_params)
if result.rows:
return bool(result.rows[0][0])
return False
return results[action]

View file

@ -512,6 +512,39 @@ Example usage:
The method returns ``True`` if the permission is granted, ``False`` if denied.
.. _datasette_allowed_many:
await .allowed_many(\*, actions, resource, actor=None)
------------------------------------------------------
``actions`` - list of strings
The names of the actions to permission check.
``resource`` - Resource object
A Resource object representing the database, table, or other resource that each action is checked against. Omit for global actions.
``actor`` - dictionary, optional
The authenticated actor. This is usually ``request.actor``. Defaults to ``None`` for unauthenticated requests.
Checks several actions against the same resource for the same actor, returning a dictionary mapping each action name to ``True`` or ``False``. The whole batch - including any actions pulled in through ``also_requires`` dependencies - is resolved with a single SQL query against the internal database, so this is much faster than calling :ref:`datasette.allowed() <datasette_allowed>` once per action.
Example usage:
.. code-block:: python
from datasette.resources import TableResource
results = await datasette.allowed_many(
actions=["insert-row", "delete-row", "drop-table"],
resource=TableResource(
database="fixtures", table="facetable"
),
actor=request.actor,
)
# {"insert-row": True, "delete-row": True, "drop-table": False}
Actions for which no plugin provides any permission rules are resolved to ``False`` directly, without being included in the SQL query at all.
.. _datasette_allowed_resources:
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)

View file

@ -1458,6 +1458,12 @@ to avoid conflicts with other plugins. The recommended convention is to prefix p
plugin's source name (e.g., ``myplugin_user_id``). The system reserves these parameter names:
``:actor``, ``:actor_id``, ``:action``, and ``:filter_parent``.
This hook may be called for many actions in rapid succession - for example
:ref:`datasette.allowed_many() <datasette_allowed_many>` gathers rules for every action in its batch
concurrently. Hook implementations must not assume that checks for different actions arrive one
page-render apart, and expensive work (such as network calls) should be cached independently of the
``action`` argument where possible.
You can also use return ``PermissionSQL.allow(reason="reason goes here")`` or ``PermissionSQL.deny(reason="reason goes here")`` as shortcuts for simple root-level allow or deny rules. These will create SQL snippets that look like this:
.. code-block:: sql

View file

@ -146,6 +146,7 @@ def restore_working_directory(tmpdir, request):
@pytest.fixture(scope="session", autouse=True)
def check_actions_are_documented():
from datasette.plugins import pm
from datasette.default_actions import register_actions as default_register_actions
content = (
pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst"
@ -154,6 +155,9 @@ def check_actions_are_documented():
documented_actions = set(permissions_re.findall(content)).union(
UNDOCUMENTED_PERMISSIONS
)
# Only Datasette core actions need to be documented - actions registered
# by (test) plugins are checked for registration but not documentation
core_actions = {action.name for action in default_register_actions()}
def before(hook_name, hook_impls, kwargs):
if hook_name == "permission_resources_sql":
@ -165,9 +169,10 @@ def check_actions_are_documented():
+ " (or maybe a test forgot to do await ds.invoke_startup())"
)
action = kwargs.get("action").replace("-", "_")
assert (
action in documented_actions
), "Undocumented permission action: {}".format(action)
if kwargs["action"] in core_actions:
assert (
action in documented_actions
), "Undocumented permission action: {}".format(action)
pm.add_hookcall_monitoring(
before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None

341
tests/test_allowed_many.py Normal file
View file

@ -0,0 +1,341 @@
"""
Tests for the datasette.allowed_many() batch permission API, which
resolves multiple actions against one resource in a single internal
database query. datasette.allowed() is implemented on top of it, so
both entry points share one resolution code path.
"""
import pytest
import pytest_asyncio
from datasette.app import Datasette
from datasette.permissions import PermissionSQL, SkipPermissions
from datasette.resources import DatabaseResource, TableResource
from datasette import hookimpl
@pytest_asyncio.fixture
async def ds():
ds = Datasette()
await ds.invoke_startup()
db = ds.add_memory_database("analytics")
await db.execute_write("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)")
await db.execute_write("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)")
await ds._refresh_schemas()
return ds
class MatrixRulesPlugin:
"""Different rules per action for actor carol, to exercise resolution."""
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if not actor or actor.get("id") != "carol":
return None
if action == "view-table":
return PermissionSQL(sql="""
SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global allow' AS reason
UNION ALL
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
""")
if action == "insert-row":
return PermissionSQL(
sql="SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analytics writes' AS reason"
)
# Everything else: no opinion (implicit deny unless defaults allow)
return None
@pytest.mark.asyncio
async def test_allowed_many_basic(ds):
plugin = MatrixRulesPlugin()
ds.pm.register(plugin, name="matrix")
try:
results = await ds.allowed_many(
actions=["view-table", "insert-row", "drop-table"],
resource=TableResource("analytics", "users"),
actor={"id": "carol"},
)
assert results == {
"view-table": True,
"insert-row": True,
"drop-table": False,
}
# Child-level deny beats global allow
sensitive = await ds.allowed_many(
actions=["view-table"],
resource=TableResource("analytics", "sensitive"),
actor={"id": "carol"},
)
assert sensitive == {"view-table": False}
finally:
ds.pm.unregister(name="matrix")
@pytest.mark.asyncio
async def test_allowed_many_matches_allowed(ds):
"""Every action resolved by allowed_many() must match allowed()."""
plugin = MatrixRulesPlugin()
ds.pm.register(plugin, name="matrix")
try:
all_actions = list(ds.actions)
for resource in (
TableResource("analytics", "users"),
TableResource("analytics", "sensitive"),
DatabaseResource("analytics"),
):
batched = await ds.allowed_many(
actions=all_actions, resource=resource, actor={"id": "carol"}
)
assert set(batched) == set(all_actions)
for action in all_actions:
individual = await ds.allowed(
action=action, resource=resource, actor={"id": "carol"}
)
assert (
batched[action] == individual
), f"Mismatch for {action} on {resource}"
finally:
ds.pm.unregister(name="matrix")
@pytest.mark.asyncio
async def test_allowed_many_unknown_action_raises(ds):
with pytest.raises(ValueError, match="Unknown action"):
await ds.allowed_many(
actions=["view-table", "no-such-action"],
resource=TableResource("analytics", "users"),
actor=None,
)
@pytest.mark.asyncio
async def test_allowed_many_empty_actions(ds):
assert (
await ds.allowed_many(
actions=[], resource=TableResource("analytics", "users"), actor=None
)
== {}
)
class AlsoRequiresRulesPlugin:
"""dave: store-query allowed but execute-sql explicitly denied.
erin: store-query allowed (execute-sql stays default-allowed)."""
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
actor_id = actor.get("id") if actor else None
if actor_id == "dave":
if action == "store-query":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'dave can store' AS reason"
)
if action == "execute-sql":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 0 AS allow, 'dave no sql' AS reason"
)
if actor_id == "erin" and action == "store-query":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'erin can store' AS reason"
)
return None
@pytest.mark.asyncio
async def test_allowed_many_also_requires(ds):
# store-query also_requires execute-sql, which also_requires view-database
plugin = AlsoRequiresRulesPlugin()
ds.pm.register(plugin, name="also_requires")
try:
resource = DatabaseResource("analytics")
dave = await ds.allowed_many(
actions=["store-query", "execute-sql", "view-database"],
resource=resource,
actor={"id": "dave"},
)
# execute-sql denied, so store-query must be denied too
assert dave == {
"store-query": False,
"execute-sql": False,
"view-database": True,
}
erin = await ds.allowed_many(
actions=["store-query"], resource=resource, actor={"id": "erin"}
)
assert erin == {"store-query": True}
# Must match the single-check path
assert (
await ds.allowed(
action="store-query", resource=resource, actor={"id": "dave"}
)
is False
)
assert (
await ds.allowed(
action="store-query", resource=resource, actor={"id": "erin"}
)
is True
)
finally:
ds.pm.unregister(name="also_requires")
@pytest.mark.asyncio
async def test_allowed_many_respects_restrictions(ds):
"""Token-style _r restrictions are enforced within the batch."""
actor = {"id": "root", "_r": {"d": {"analytics": ["vt"]}}}
results = await ds.allowed_many(
actions=["view-table", "drop-table"],
resource=TableResource("analytics", "users"),
actor=actor,
)
# root could normally do both, but the token only allows view-table
# on the analytics database
assert results == {"view-table": True, "drop-table": False}
other_db = await ds.allowed_many(
actions=["view-table"],
resource=TableResource("production", "stuff"),
actor=actor,
)
assert other_db == {"view-table": False}
# Equivalence with allowed()
assert (
await ds.allowed(
action="view-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
is True
)
assert (
await ds.allowed(
action="drop-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
is False
)
class ParamCollisionPlugin:
"""Same parameter name with a different value for every action."""
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if not actor or actor.get("id") != "paula":
return None
flag = 1 if action in ("drop-table", "insert-row") else 0
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, :flag AS allow, 'flagged' AS reason",
params={"flag": flag},
)
@pytest.mark.asyncio
async def test_allowed_many_namespaces_params_across_actions(ds):
"""Many actions whose rules use identical param names must not collide."""
plugin = ParamCollisionPlugin()
ds.pm.register(plugin, name="collision")
try:
all_actions = list(ds.actions)
assert len(all_actions) >= 15
resource = TableResource("analytics", "users")
results = await ds.allowed_many(
actions=all_actions, resource=resource, actor={"id": "paula"}
)
# Spot-check: only the flagged actions resolve True
assert results["drop-table"] is True
assert results["create-table"] is False
# Full equivalence against single checks
for action in all_actions:
assert results[action] == await ds.allowed(
action=action, resource=resource, actor={"id": "paula"}
), f"Mismatch for {action}"
finally:
ds.pm.unregister(name="collision")
@pytest.mark.asyncio
async def test_allowed_many_single_internal_db_query(ds):
internal_db = ds.get_internal_database()
calls = []
original_execute = internal_db.execute
async def counting_execute(sql, params=None, **kwargs):
calls.append(sql)
return await original_execute(sql, params, **kwargs)
internal_db.execute = counting_execute
try:
results = await ds.allowed_many(
actions=["view-table", "insert-row", "delete-row", "drop-table"],
resource=TableResource("analytics", "users"),
actor={"id": "root", "_r": {"d": {"analytics": ["vt"]}}},
)
assert len(results) == 4
assert len(calls) == 1
finally:
internal_db.execute = original_execute
@pytest.mark.asyncio
async def test_allowed_many_no_query_when_no_rules(ds):
"""Actions with no rules from any plugin are denied without SQL.
Restrictions can only restrict, never grant, so an action with no
rule rows is always False - it should not contribute to the query,
and if no action has rules there should be no query at all."""
internal_db = ds.get_internal_database()
calls = []
original_execute = internal_db.execute
async def counting_execute(sql, params=None, **kwargs):
calls.append(sql)
return await original_execute(sql, params, **kwargs)
internal_db.execute = counting_execute
try:
# bob gets no rules at all for these write actions
results = await ds.allowed_many(
actions=["drop-table", "delete-row"],
resource=TableResource("analytics", "users"),
actor={"id": "bob"},
)
assert results == {"drop-table": False, "delete-row": False}
assert len(calls) == 0
# A mixed batch still needs exactly one query
calls.clear()
results = await ds.allowed_many(
actions=["view-table", "drop-table"],
resource=TableResource("analytics", "users"),
actor={"id": "bob"},
)
assert results == {"view-table": True, "drop-table": False}
assert len(calls) == 1
finally:
internal_db.execute = original_execute
@pytest.mark.asyncio
async def test_allowed_many_global_actions_without_resource(ds):
results = await ds.allowed_many(
actions=["view-instance", "permissions-debug"],
actor={"id": "root"},
)
assert results["view-instance"] is True
# Equivalence with single checks for global actions
for action in ("view-instance", "permissions-debug"):
assert results[action] == await ds.allowed(action=action, actor={"id": "root"})
anon = await ds.allowed_many(actions=["permissions-debug"], actor=None)
assert anon == {"permissions-debug": False}
@pytest.mark.asyncio
async def test_allowed_many_skip_permission_checks(ds):
with SkipPermissions():
results = await ds.allowed_many(
actions=["view-table", "drop-table"],
resource=TableResource("analytics", "users"),
actor=None,
)
assert results == {"view-table": True, "drop-table": True}