New allowed_resources_sql plugin hook and debug tools (#2505)

* allowed_resources_sql plugin hook and infrastructure
* New methods for checking permissions with the new system
* New /-/allowed and /-/check and /-/rules special endpoints

Still needs to be integrated more deeply into Datasette, especially for listing visible tables.

Refs: #2502

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2025-10-08 14:27:51 -07:00 committed by GitHub
commit 27084caa04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 3381 additions and 27 deletions

View file

@ -49,6 +49,9 @@ from .views.special import (
AllowDebugView,
PermissionsDebugView,
MessagesDebugView,
AllowedResourcesView,
PermissionRulesView,
PermissionCheckView,
)
from .views.table import (
TableInsertView,
@ -111,6 +114,8 @@ from .tracer import AsgiTracer
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
from .version import __version__
from .utils.permissions import build_rules_union, PluginSQL
app_root = Path(__file__).parent.parent
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015
@ -1030,6 +1035,149 @@ class Datasette:
)
return result
async def allowed_resources_sql(
self, actor: dict | None, action: str
) -> tuple[str, dict]:
"""Combine permission_resources_sql PluginSQL blocks into a UNION query.
Returns a (sql, params) tuple suitable for execution against SQLite.
"""
plugin_blocks: List[PluginSQL] = []
for block in pm.hook.permission_resources_sql(
datasette=self,
actor=actor,
action=action,
):
block = await await_me_maybe(block)
if block is None:
continue
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
continue
if not isinstance(candidate, PluginSQL):
continue
plugin_blocks.append(candidate)
actor_id = actor.get("id") if actor else None
sql, params = build_rules_union(
actor=str(actor_id) if actor_id is not None else "",
plugins=plugin_blocks,
)
return sql, params
async def permission_allowed_2(
self, actor, action, resource=None, *, default=DEFAULT_NOT_SET
):
"""Permission check backed by permission_resources_sql rules."""
if default is DEFAULT_NOT_SET and action in self.permissions:
default = self.permissions[action].default
if isinstance(actor, dict) or actor is None:
actor_dict = actor
else:
actor_dict = {"id": actor}
actor_id = actor_dict.get("id") if actor_dict else None
candidate_parent = None
candidate_child = None
if isinstance(resource, str):
candidate_parent = resource
elif isinstance(resource, (tuple, list)) and len(resource) == 2:
candidate_parent, candidate_child = resource
elif resource is not None:
raise TypeError("resource must be None, str, or (parent, child) tuple")
union_sql, union_params = await self.allowed_resources_sql(actor_dict, action)
query = f"""
WITH rules AS (
{union_sql}
),
candidate AS (
SELECT :cand_parent AS parent, :cand_child AS child
),
matched AS (
SELECT
r.allow,
r.reason,
r.source_plugin,
CASE
WHEN r.child IS NOT NULL THEN 2
WHEN r.parent IS NOT NULL THEN 1
ELSE 0
END AS depth
FROM rules r
JOIN candidate c
ON (r.parent IS NULL OR r.parent = c.parent)
AND (r.child IS NULL OR r.child = c.child)
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (
ORDER BY
depth DESC,
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
source_plugin
) AS rn
FROM matched
),
winner AS (
SELECT allow, reason, source_plugin, depth
FROM ranked
WHERE rn = 1
)
SELECT allow, reason, source_plugin, depth FROM winner
"""
params = {
**union_params,
"cand_parent": candidate_parent,
"cand_child": candidate_child,
}
rows = await self.get_internal_database().execute(query, params)
row = rows.first()
reason = None
source_plugin = None
depth = None
used_default = False
if row is None:
result = default
used_default = True
else:
allow = row["allow"]
reason = row["reason"]
source_plugin = row["source_plugin"]
depth = row["depth"]
if allow is None:
result = default
used_default = True
else:
result = bool(allow)
self._permission_checks.append(
{
"when": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"actor": actor,
"action": action,
"resource": resource,
"used_default": used_default,
"result": result,
"reason": reason,
"source_plugin": source_plugin,
"depth": depth,
}
)
return result
async def ensure_permissions(
self,
actor: dict,
@ -1586,6 +1734,18 @@ class Datasette:
PermissionsDebugView.as_view(self),
r"/-/permissions$",
)
add_route(
AllowedResourcesView.as_view(self),
r"/-/allowed(\.(?P<format>json))?$",
)
add_route(
PermissionRulesView.as_view(self),
r"/-/rules(\.(?P<format>json))?$",
)
add_route(
PermissionCheckView.as_view(self),
r"/-/check(\.(?P<format>json))?$",
)
add_route(
MessagesDebugView.as_view(self),
r"/-/messages$",