From 04d88cb4969cbe3caead5392a6abf2b4a1cd760f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 Sep 2025 15:00:30 -0700 Subject: [PATCH] New /-/allowed and /-/check and /-/rules special endpoints --- datasette/app.py | 27 ++- datasette/views/special.py | 338 +++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+), 2 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index dbf90e3b..ee2401ea 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -6,6 +6,7 @@ import collections import dataclasses import datetime import functools +import logging import glob import hashlib import httpx @@ -49,6 +50,9 @@ from .views.special import ( AllowDebugView, PermissionsDebugView, MessagesDebugView, + AllowedResourcesView, + PermissionRulesView, + PermissionCheckView, ) from .views.table import ( TableInsertView, @@ -110,6 +114,8 @@ from .utils.sqlite import ( from .tracer import AsgiTracer from .plugins import pm, DEFAULT_PLUGINS, get_plugins from .version import __version__ + +logger = logging.getLogger(__name__) from .utils.permissions import build_rules_union, PluginSQL app_root = Path(__file__).parent.parent @@ -1046,9 +1052,11 @@ class Datasette: if block is None: continue if not isinstance(block, PluginSQL): - raise TypeError( - "permission_resources_sql plugins must return PluginSQL instances" + logger.warning( + "Skipping permission_resources_sql result %r from plugin; expected PluginSQL", + block, ) + continue plugin_blocks.append(block) sql, params = build_rules_union(actor=actor_id, plugins=plugin_blocks) @@ -1132,6 +1140,7 @@ class Datasette: reason = None source_plugin = None + depth = None used_default = False if row is None: @@ -1141,6 +1150,7 @@ class Datasette: allow = row["allow"] reason = row["reason"] source_plugin = row["source_plugin"] + depth = row["depth"] if allow is None: result = default used_default = True @@ -1157,6 +1167,7 @@ class Datasette: "result": result, "reason": reason, "source_plugin": source_plugin, + "depth": depth, } ) @@ -1718,6 +1729,18 @@ class Datasette: PermissionsDebugView.as_view(self), r"/-/permissions$", ) + add_route( + AllowedResourcesView.as_view(self), + r"/-/allowed$", + ) + add_route( + PermissionRulesView.as_view(self), + r"/-/rules$", + ) + add_route( + PermissionCheckView.as_view(self), + r"/-/check$", + ) add_route( MessagesDebugView.as_view(self), r"/-/messages$", diff --git a/datasette/views/special.py b/datasette/views/special.py index e6fbc9f3..6ec6d9b1 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,17 +1,32 @@ import json +import logging from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent from datasette.utils.asgi import Response, Forbidden from datasette.utils import ( actor_matches_allow, add_cors_headers, + await_me_maybe, tilde_encode, tilde_decode, ) +from datasette.utils.permissions import PluginSQL, resolve_permissions_from_catalog +from datasette.plugins import pm from .base import BaseView, View import secrets import urllib +logger = logging.getLogger(__name__) + + +def _resource_path(parent, child): + if parent is None: + return "/" + if child is None: + return f"/{parent}" + return f"/{parent}/{child}" + + class JsonDataView(BaseView): name = "json_data" @@ -187,6 +202,329 @@ class PermissionsDebugView(BaseView): ) +class AllowedResourcesView(BaseView): + name = "allowed" + has_json_alternate = False + + CANDIDATE_SQL = { + "view-table": ( + "SELECT database_name AS parent, table_name AS child FROM catalog_tables", + {}, + ), + "view-database": ( + "SELECT database_name AS parent, NULL AS child FROM catalog_databases", + {}, + ), + "view-instance": ("SELECT NULL AS parent, NULL AS child", {}), + "execute-sql": ( + "SELECT database_name AS parent, NULL AS child FROM catalog_databases", + {}, + ), + } + + async def get(self, request): + await self.ds.ensure_permissions(request.actor, ["view-instance"]) + action = request.args.get("action") + if not action: + return Response.json({"error": "action parameter is required"}, status=400) + if action not in self.ds.permissions: + return Response.json({"error": f"Unknown action: {action}"}, status=404) + if action not in self.CANDIDATE_SQL: + return Response.json( + {"error": f"Action '{action}' is not supported by this endpoint"}, + status=400, + ) + + actor_id = (request.actor or {}).get("id") if request.actor else None + + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("page_size", "50")) + except ValueError: + return Response.json( + {"error": "page and page_size must be integers"}, status=400 + ) + if page < 1: + return Response.json({"error": "page must be >= 1"}, status=400) + if page_size < 1: + return Response.json({"error": "page_size must be >= 1"}, status=400) + max_page_size = 200 + if page_size > max_page_size: + page_size = max_page_size + offset = (page - 1) * page_size + + candidate_sql, candidate_params = self.CANDIDATE_SQL[action] + + db = self.ds.get_internal_database() + required_tables = set() + if "catalog_tables" in candidate_sql: + required_tables.add("catalog_tables") + if "catalog_databases" in candidate_sql: + required_tables.add("catalog_databases") + + for table in required_tables: + if not await db.table_exists(table): + headers = {} + if self.ds.cors: + add_cors_headers(headers) + return Response.json( + { + "action": action, + "actor_id": actor_id, + "page": page, + "page_size": page_size, + "total": 0, + "items": [], + }, + headers=headers, + ) + + plugins = [] + for block in pm.hook.permission_resources_sql( + datasette=self.ds, + actor_id=actor_id, + action=action, + ): + block = await await_me_maybe(block) + if block is None: + continue + if not isinstance(block, PluginSQL): + logger.warning( + "Skipping permission_resources_sql result %r from plugin; expected PluginSQL", + block, + ) + continue + plugins.append(block) + + rows = await resolve_permissions_from_catalog( + db, + actor=str(actor_id) if actor_id is not None else "", + plugins=plugins, + action=action, + candidate_sql=candidate_sql, + candidate_params=candidate_params, + implicit_deny=True, + ) + + allowed_rows = [row for row in rows if row["allow"] == 1] + total = len(allowed_rows) + paged_rows = allowed_rows[offset : offset + page_size] + + items = [ + { + "parent": row["parent"], + "child": row["child"], + "resource": row["resource"], + "reason": row["reason"], + "source_plugin": row["source_plugin"], + } + for row in paged_rows + ] + + def build_page_url(page_number): + pairs = [] + for key in request.args: + if key in {"page", "page_size"}: + continue + for value in request.args.getlist(key): + pairs.append((key, value)) + pairs.append(("page", str(page_number))) + pairs.append(("page_size", str(page_size))) + query = urllib.parse.urlencode(pairs) + return f"{request.path}?{query}" + + response = { + "action": action, + "actor_id": actor_id, + "page": page, + "page_size": page_size, + "total": total, + "items": items, + } + + if total > offset + page_size: + response["next_url"] = build_page_url(page + 1) + if page > 1: + response["previous_url"] = build_page_url(page - 1) + + headers = {} + if self.ds.cors: + add_cors_headers(headers) + return Response.json(response, headers=headers) + + +class PermissionRulesView(BaseView): + name = "permission_rules" + has_json_alternate = False + + async def get(self, request): + await self.ds.ensure_permissions(request.actor, ["view-instance"]) + action = request.args.get("action") + if not action: + return Response.json({"error": "action parameter is required"}, status=400) + if action not in self.ds.permissions: + return Response.json({"error": f"Unknown action: {action}"}, status=404) + + actor_id = (request.actor or {}).get("id") if request.actor else None + + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("page_size", "50")) + except ValueError: + return Response.json( + {"error": "page and page_size must be integers"}, status=400 + ) + if page < 1: + return Response.json({"error": "page must be >= 1"}, status=400) + if page_size < 1: + return Response.json({"error": "page_size must be >= 1"}, status=400) + max_page_size = 200 + if page_size > max_page_size: + page_size = max_page_size + offset = (page - 1) * page_size + + union_sql, union_params = await self.ds.allowed_resources_sql(actor_id, action) + db = self.ds.get_internal_database() + + count_query = f""" + WITH rules AS ( + {union_sql} + ) + SELECT COUNT(*) AS count + FROM rules + WHERE allow = 1 + """ + count_row = (await db.execute(count_query, union_params)).first() + total = count_row["count"] if count_row else 0 + + data_query = f""" + WITH rules AS ( + {union_sql} + ) + SELECT parent, child, allow, reason, source_plugin + FROM rules + WHERE allow = 1 + ORDER BY (parent IS NOT NULL), parent, child + LIMIT :limit OFFSET :offset + """ + params = {**union_params, "limit": page_size, "offset": offset} + rows = await db.execute(data_query, params) + + items = [] + for row in rows: + parent = row["parent"] + child = row["child"] + items.append( + { + "parent": parent, + "child": child, + "resource": _resource_path(parent, child), + "reason": row["reason"], + "source_plugin": row["source_plugin"], + } + ) + + def build_page_url(page_number): + pairs = [] + for key in request.args: + if key in {"page", "page_size"}: + continue + for value in request.args.getlist(key): + pairs.append((key, value)) + pairs.append(("page", str(page_number))) + pairs.append(("page_size", str(page_size))) + query = urllib.parse.urlencode(pairs) + return f"{request.path}?{query}" + + response = { + "action": action, + "actor_id": actor_id, + "page": page, + "page_size": page_size, + "total": total, + "items": items, + } + + if total > offset + page_size: + response["next_url"] = build_page_url(page + 1) + if page > 1: + response["previous_url"] = build_page_url(page - 1) + + headers = {} + if self.ds.cors: + add_cors_headers(headers) + return Response.json(response, headers=headers) + + +class PermissionCheckView(BaseView): + name = "permission_check" + has_json_alternate = False + + async def get(self, request): + await self.ds.ensure_permissions(request.actor, ["view-instance"]) + action = request.args.get("action") + if not action: + return Response.json({"error": "action parameter is required"}, status=400) + if action not in self.ds.permissions: + return Response.json({"error": f"Unknown action: {action}"}, status=404) + + parent = request.args.get("parent") + child = request.args.get("child") + if child and not parent: + return Response.json( + {"error": "parent is required when child is provided"}, status=400 + ) + + if parent and child: + resource = (parent, child) + elif parent: + resource = parent + else: + resource = None + + before_checks = len(self.ds._permission_checks) + allowed = await self.ds.permission_allowed_2(request.actor, action, resource) + + info = None + if len(self.ds._permission_checks) > before_checks: + for check in reversed(self.ds._permission_checks): + if ( + check.get("actor") == request.actor + and check.get("action") == action + and check.get("resource") == resource + ): + info = check + break + + response = { + "action": action, + "allowed": bool(allowed), + "resource": { + "parent": parent, + "child": child, + "path": _resource_path(parent, child), + }, + } + + if request.actor and "id" in request.actor: + response["actor_id"] = request.actor["id"] + + if info is not None: + response.update( + { + "used_default": info.get("used_default"), + "reason": info.get("reason"), + "source_plugin": info.get("source_plugin"), + "depth": info.get("depth"), + } + ) + + headers = {} + if self.ds.cors: + add_cors_headers(headers) + return Response.json(response, headers=headers) + + class AllowDebugView(BaseView): name = "allow_debug" has_json_alternate = False