diff --git a/datasette/app.py b/datasette/app.py index bf6cc03f..6c7026a8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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(\.(?Pjson))?$", + ) + add_route( + PermissionRulesView.as_view(self), + r"/-/rules(\.(?Pjson))?$", + ) + add_route( + PermissionCheckView.as_view(self), + r"/-/check(\.(?Pjson))?$", + ) add_route( MessagesDebugView.as_view(self), r"/-/messages$", diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 757b3a46..a9534cab 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -1,8 +1,8 @@ from datasette import hookimpl, Permission +from datasette.utils.permissions import PluginSQL from datasette.utils import actor_matches_allow import itsdangerous import time -from typing import Union, Tuple @hookimpl @@ -172,6 +172,163 @@ def permission_allowed_default(datasette, actor, action, resource): return inner +@hookimpl +async def permission_resources_sql(datasette, actor, action): + rules: list[PluginSQL] = [] + + config_rules = await _config_permission_rules(datasette, actor, action) + rules.extend(config_rules) + + default_allow_actions = { + "view-instance", + "view-database", + "view-table", + "execute-sql", + } + if action in default_allow_actions: + reason = f"default allow for {action}".replace("'", "''") + sql = ( + "SELECT NULL AS parent, NULL AS child, 1 AS allow, " f"'{reason}' AS reason" + ) + rules.append( + PluginSQL( + source="default_permissions", + sql=sql, + params={}, + ) + ) + + if not rules: + return None + if len(rules) == 1: + return rules[0] + return rules + + +async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]: + config = datasette.config or {} + + if actor is None: + actor_dict: dict | None = None + elif isinstance(actor, dict): + actor_dict = actor + else: + actor_lookup = await datasette.actors_from_ids([actor]) + actor_dict = actor_lookup.get(actor) or {"id": actor} + + def evaluate(allow_block): + if allow_block is None: + return None + return actor_matches_allow(actor_dict, allow_block) + + rows = [] + + def add_row(parent, child, result, scope): + if result is None: + return + rows.append( + ( + parent, + child, + bool(result), + f"config {'allow' if result else 'deny'} {scope}", + ) + ) + + root_perm = (config.get("permissions") or {}).get(action) + add_row(None, None, evaluate(root_perm), f"permissions for {action}") + + for db_name, db_config in (config.get("databases") or {}).items(): + db_perm = (db_config.get("permissions") or {}).get(action) + add_row( + db_name, None, evaluate(db_perm), f"permissions for {action} on {db_name}" + ) + + for table_name, table_config in (db_config.get("tables") or {}).items(): + table_perm = (table_config.get("permissions") or {}).get(action) + add_row( + db_name, + table_name, + evaluate(table_perm), + f"permissions for {action} on {db_name}/{table_name}", + ) + + if action == "view-table": + table_allow = (table_config or {}).get("allow") + add_row( + db_name, + table_name, + evaluate(table_allow), + f"allow for {action} on {db_name}/{table_name}", + ) + + for query_name, query_config in (db_config.get("queries") or {}).items(): + query_perm = (query_config.get("permissions") or {}).get(action) + add_row( + db_name, + query_name, + evaluate(query_perm), + f"permissions for {action} on {db_name}/{query_name}", + ) + if action == "view-query": + query_allow = (query_config or {}).get("allow") + add_row( + db_name, + query_name, + evaluate(query_allow), + f"allow for {action} on {db_name}/{query_name}", + ) + + if action == "view-database": + db_allow = db_config.get("allow") + add_row( + db_name, None, evaluate(db_allow), f"allow for {action} on {db_name}" + ) + + if action == "execute-sql": + db_allow_sql = db_config.get("allow_sql") + add_row(db_name, None, evaluate(db_allow_sql), f"allow_sql for {db_name}") + + if action == "view-instance": + allow_block = config.get("allow") + add_row(None, None, evaluate(allow_block), "allow for view-instance") + + if action == "view-table": + # Tables handled in loop + pass + + if action == "view-query": + # Queries handled in loop + pass + + if action == "execute-sql": + allow_sql = config.get("allow_sql") + add_row(None, None, evaluate(allow_sql), "allow_sql") + + if action == "view-database": + # already handled per-database + pass + + if not rows: + return [] + + parts = [] + params = {} + for idx, (parent, child, allow, reason) in enumerate(rows): + key = f"cfg_{idx}" + parts.append( + f"SELECT :{key}_parent AS parent, :{key}_child AS child, :{key}_allow AS allow, :{key}_reason AS reason" + ) + params[f"{key}_parent"] = parent + params[f"{key}_child"] = child + params[f"{key}_allow"] = 1 if allow else 0 + params[f"{key}_reason"] = reason + + sql = "\nUNION ALL\n".join(parts) + print(sql, params) + return [PluginSQL(source="config_permissions", sql=sql, params=params)] + + async def _resolve_config_permissions_blocks(datasette, actor, action, resource): # Check custom permissions: blocks config = datasette.config or {} @@ -277,7 +434,7 @@ def restrictions_allow_action( datasette: "Datasette", restrictions: dict, action: str, - resource: Union[str, Tuple[str, str]], + resource: str | tuple[str, str], ): "Do these restrictions allow the requested action against the requested resource?" if action == "view-instance": diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index bcc2e229..eedb2481 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -115,6 +115,18 @@ def permission_allowed(datasette, actor, action, resource): """Check if actor is allowed to perform this action - return True, False or None""" +@hookspec +def permission_resources_sql(datasette, actor, action): + """Return SQL query fragments for permission checks on resources. + + Returns None, a PluginSQL object, or a list of PluginSQL objects. + Each PluginSQL contains SQL that should return rows with columns: + parent (str|None), child (str|None), allow (int), reason (str). + + Used to efficiently check permissions across multiple resources at once. + """ + + @hookspec def canned_queries(datasette, database, actor): """Return a dictionary of canned query definitions or an awaitable function that returns them""" diff --git a/datasette/templates/_permission_ui_styles.html b/datasette/templates/_permission_ui_styles.html new file mode 100644 index 00000000..53a824f1 --- /dev/null +++ b/datasette/templates/_permission_ui_styles.html @@ -0,0 +1,145 @@ + diff --git a/datasette/templates/debug_allowed.html b/datasette/templates/debug_allowed.html new file mode 100644 index 00000000..5f22b6a4 --- /dev/null +++ b/datasette/templates/debug_allowed.html @@ -0,0 +1,294 @@ +{% extends "base.html" %} + +{% block title %}Allowed Resources{% endblock %} + +{% block extra_head %} + +{% include "_permission_ui_styles.html" %} +{% endblock %} + +{% block content %} + +

Allowed Resources

+ +

Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the /-/allowed.json API endpoint.

+ +{% if request.actor %} +

Current actor: {{ request.actor.get("id", "anonymous") }}

+{% else %} +

Current actor: anonymous (not logged in)

+{% endif %} + +
+
+
+ + + Only certain actions are supported by this endpoint +
+ +
+ + + Filter results to a specific parent resource +
+ +
+ + + Filter results to a specific child resource (requires parent) +
+ +
+ + + Number of results per page (max 200) +
+ +
+ +
+
+
+ + + + + +{% endblock %} diff --git a/datasette/templates/debug_check.html b/datasette/templates/debug_check.html new file mode 100644 index 00000000..b8bbd0a6 --- /dev/null +++ b/datasette/templates/debug_check.html @@ -0,0 +1,350 @@ +{% extends "base.html" %} + +{% block title %}Permission Check{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} + +

Permission Check

+ +

Use this tool to test permission checks for the current actor. It queries the /-/check.json API endpoint.

+ +{% if request.actor %} +

Current actor: {{ request.actor.get("id", "anonymous") }}

+{% else %} +

Current actor: anonymous (not logged in)

+{% endif %} + +
+
+ + + The permission action to check +
+ +
+ + + For database-level permissions, specify the database name +
+ +
+ + + For table-level permissions, specify the table name (requires parent) +
+ + +
+ + + + + +{% endblock %} diff --git a/datasette/templates/debug_rules.html b/datasette/templates/debug_rules.html new file mode 100644 index 00000000..f45daf2f --- /dev/null +++ b/datasette/templates/debug_rules.html @@ -0,0 +1,268 @@ +{% extends "base.html" %} + +{% block title %}Permission Rules{% endblock %} + +{% block extra_head %} + +{% include "_permission_ui_styles.html" %} +{% endblock %} + +{% block content %} + +

Permission Rules

+ +

Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the /-/rules.json API endpoint.

+ +{% if request.actor %} +

Current actor: {{ request.actor.get("id", "anonymous") }}

+{% else %} +

Current actor: anonymous (not logged in)

+{% endif %} + +
+
+
+ + + The permission action to check +
+ +
+ + + Number of results per page (max 200) +
+ +
+ +
+
+
+ + + + + +{% endblock %} diff --git a/datasette/utils/permissions.py b/datasette/utils/permissions.py new file mode 100644 index 00000000..7dc2eb4d --- /dev/null +++ b/datasette/utils/permissions.py @@ -0,0 +1,244 @@ +# perm_utils.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union +import sqlite3 + + +# ----------------------------- +# Plugin interface & utilities +# ----------------------------- + + +@dataclass +class PluginSQL: + """ + A plugin contributes SQL that yields: + parent TEXT NULL, + child TEXT NULL, + allow INTEGER, -- 1 allow, 0 deny + reason TEXT + """ + + source: str # identifier used for auditing (e.g., plugin name) + sql: str # SQL that SELECTs the 4 columns above + params: Dict[str, Any] # bound params for the SQL (values only; no ':' prefix) + + +def _namespace_params(i: int, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """ + Rewrite parameter placeholders to distinct names per plugin block. + Returns (rewritten_sql, namespaced_params). + """ + + replacements = {key: f"{key}_{i}" for key in params.keys()} + + def rewrite(s: str) -> str: + for key in sorted(replacements.keys(), key=len, reverse=True): + s = s.replace(f":{key}", f":{replacements[key]}") + return s + + namespaced: Dict[str, Any] = {} + for key, value in params.items(): + namespaced[replacements[key]] = value + return rewrite, namespaced + + +PluginProvider = Callable[[str], PluginSQL] +PluginOrFactory = Union[PluginSQL, PluginProvider] + + +def build_rules_union( + actor: str, plugins: Sequence[PluginSQL] +) -> Tuple[str, Dict[str, Any]]: + """ + Compose plugin SQL into a UNION ALL with namespaced parameters. + + Returns: + union_sql: a SELECT with columns (parent, child, allow, reason, source_plugin) + params: dict of bound parameters including :actor and namespaced plugin params + """ + parts: List[str] = [] + params: Dict[str, Any] = {"actor": actor} + + for i, p in enumerate(plugins): + rewrite, ns_params = _namespace_params(i, p.params) + sql_block = rewrite(p.sql) + params.update(ns_params) + + parts.append( + f""" + SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM ( + {sql_block} + ) + """.strip() + ) + + if not parts: + # Empty UNION that returns no rows + union_sql = "SELECT NULL parent, NULL child, NULL allow, NULL reason, 'none' source_plugin WHERE 0" + else: + union_sql = "\nUNION ALL\n".join(parts) + + return union_sql, params + + +# ----------------------------------------------- +# Core resolvers (no temp tables, no custom UDFs) +# ----------------------------------------------- + + +async def resolve_permissions_from_catalog( + db, + actor: str, + plugins: Sequence[PluginOrFactory], + action: str, + candidate_sql: str, + candidate_params: Optional[Dict[str, Any]] = None, + *, + implicit_deny: bool = True, +) -> List[Dict[str, Any]]: + """ + Resolve permissions by embedding the provided *candidate_sql* in a CTE. + + Expectations: + - candidate_sql SELECTs: parent TEXT, child TEXT + (Use child=NULL for parent-scoped actions like "execute-sql".) + - *db* exposes: rows = await db.execute(sql, params) + where rows is an iterable of sqlite3.Row + - plugins are either PluginSQL objects or callables accepting (action: str) + and returning PluginSQL instances selecting (parent, child, allow, reason) + + Decision policy: + 1) Specificity first: child (depth=2) > parent (depth=1) > root (depth=0) + 2) Within the same depth: deny (0) beats allow (1) + 3) If no matching rule: + - implicit_deny=True -> treat as allow=0, reason='implicit deny' + - implicit_deny=False -> allow=None, reason=None + + Returns: list of dict rows + - parent, child, allow, reason, source_plugin, depth + - resource (rendered "/parent/child" or "/parent" or "/") + """ + resolved_plugins: List[PluginSQL] = [] + for plugin in plugins: + if callable(plugin) and not isinstance(plugin, PluginSQL): + resolved = plugin(action) # type: ignore[arg-type] + else: + resolved = plugin # type: ignore[assignment] + if not isinstance(resolved, PluginSQL): + raise TypeError("Plugin providers must return PluginSQL instances") + resolved_plugins.append(resolved) + + union_sql, rule_params = build_rules_union(actor, resolved_plugins) + all_params = { + **(candidate_params or {}), + **rule_params, + "actor": actor, + "action": action, + } + + sql = f""" + WITH + cands AS ( + {candidate_sql} + ), + rules AS ( + {union_sql} + ), + matched AS ( + SELECT + c.parent, c.child, + r.allow, r.reason, r.source_plugin, + CASE + WHEN r.child IS NOT NULL THEN 2 -- child-level (most specific) + WHEN r.parent IS NOT NULL THEN 1 -- parent-level + ELSE 0 -- root/global + END AS depth + FROM cands c + JOIN rules r + 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 ( + PARTITION BY parent, child + ORDER BY + depth DESC, -- specificity first + CASE WHEN allow=0 THEN 0 ELSE 1 END, -- deny over allow at same depth + source_plugin -- stable tie-break + ) AS rn + FROM matched + ), + winner AS ( + SELECT parent, child, + allow, reason, source_plugin, depth + FROM ranked WHERE rn = 1 + ) + SELECT + c.parent, c.child, + COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow, + COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason, + w.source_plugin, + COALESCE(w.depth, -1) AS depth, + :action AS action, + CASE + WHEN c.parent IS NULL THEN '/' + WHEN c.child IS NULL THEN '/' || c.parent + ELSE '/' || c.parent || '/' || c.child + END AS resource + FROM cands c + LEFT JOIN winner w + ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL)) + AND ((w.child = c.child ) OR (w.child IS NULL AND c.child IS NULL)) + ORDER BY c.parent, c.child + """ + + rows_iter: Iterable[sqlite3.Row] = await db.execute( + sql, + {**all_params, "implicit_deny": 1 if implicit_deny else 0}, + ) + return [dict(r) for r in rows_iter] + + +async def resolve_permissions_with_candidates( + db, + actor: str, + plugins: Sequence[PluginOrFactory], + candidates: List[Tuple[str, Optional[str]]], + action: str, + *, + implicit_deny: bool = True, +) -> List[Dict[str, Any]]: + """ + Resolve permissions without any external candidate table by embedding + the candidates as a UNION of parameterized SELECTs in a CTE. + + candidates: list of (parent, child) where child can be None for parent-scoped actions. + """ + # Build a small CTE for candidates. + cand_rows_sql: List[str] = [] + cand_params: Dict[str, Any] = {} + for i, (parent, child) in enumerate(candidates): + pkey = f"cand_p_{i}" + ckey = f"cand_c_{i}" + cand_params[pkey] = parent + cand_params[ckey] = child + cand_rows_sql.append(f"SELECT :{pkey} AS parent, :{ckey} AS child") + candidate_sql = ( + "\nUNION ALL\n".join(cand_rows_sql) + if cand_rows_sql + else "SELECT NULL AS parent, NULL AS child WHERE 0" + ) + + return await resolve_permissions_from_catalog( + db, + actor, + plugins, + action, + candidate_sql=candidate_sql, + candidate_params=cand_params, + implicit_deny=implicit_deny, + ) diff --git a/datasette/views/base.py b/datasette/views/base.py index bdc9f742..ea48a398 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -1,6 +1,7 @@ import asyncio import csv import hashlib +import json import sys import textwrap import time @@ -173,6 +174,24 @@ class BaseView: headers=headers, ) + async def respond_json_or_html(self, request, data, filename): + """Return JSON or HTML with pretty JSON depending on format parameter.""" + as_format = request.url_vars.get("format") + if as_format: + headers = {} + if self.ds.cors: + add_cors_headers(headers) + return Response.json(data, headers=headers) + else: + return await self.render( + ["show_json.html"], + request=request, + context={ + "filename": filename, + "data_json": json.dumps(data, indent=4, default=repr), + }, + ) + @classmethod def as_view(cls, *class_args, **class_kwargs): async def view(request, send): diff --git a/datasette/views/special.py b/datasette/views/special.py index e6fbc9f3..7e5ce517 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" @@ -30,32 +45,13 @@ class JsonDataView(BaseView): self.permission = permission async def get(self, request): - as_format = request.url_vars["format"] if self.permission: await self.ds.ensure_permissions(request.actor, [self.permission]) if self.needs_request: data = self.data_callback(request) else: data = self.data_callback() - if as_format: - headers = {} - if self.ds.cors: - add_cors_headers(headers) - return Response( - json.dumps(data, default=repr), - content_type="application/json; charset=utf-8", - headers=headers, - ) - - else: - return await self.render( - ["show_json.html"], - request=request, - context={ - "filename": self.filename, - "data_json": json.dumps(data, indent=4, default=repr), - }, - ) + return await self.respond_json_or_html(request, data, self.filename) class PatternPortfolioView(View): @@ -187,6 +183,402 @@ 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.refresh_schemas() + + # Check if user has permissions-debug (to show sensitive fields) + has_debug_permission = await self.ds.permission_allowed( + request.actor, "permissions-debug" + ) + + # Check if this is a request for JSON (has .json extension) + as_format = request.url_vars.get("format") + + if not as_format: + # Render the HTML form (even if query parameters are present) + return await self.render( + ["debug_allowed.html"], + request, + { + "supported_actions": sorted(self.CANDIDATE_SQL.keys()), + }, + ) + + # JSON API - action parameter is required + 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 = request.actor if isinstance(request.actor, dict) else None + parent_filter = request.args.get("parent") + child_filter = request.args.get("child") + if child_filter and not parent_filter: + return Response.json( + {"error": "parent must be provided when child is specified"}, + status=400, + ) + + 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 or {}).get("id") if actor else None, + "page": page, + "page_size": page_size, + "total": 0, + "items": [], + }, + headers=headers, + ) + + plugins = [] + for block in pm.hook.permission_resources_sql( + datasette=self.ds, + 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): + logger.warning( + "Skipping permission_resources_sql result %r from plugin; expected PluginSQL", + candidate, + ) + continue + plugins.append(candidate) + + actor_id = actor.get("id") if actor else None + 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] + if parent_filter is not None: + allowed_rows = [ + row for row in allowed_rows if row["parent"] == parent_filter + ] + if child_filter is not None: + allowed_rows = [row for row in allowed_rows if row["child"] == child_filter] + total = len(allowed_rows) + paged_rows = allowed_rows[offset : offset + page_size] + + items = [] + for row in paged_rows: + item = { + "parent": row["parent"], + "child": row["child"], + "resource": row["resource"], + } + # Only include sensitive fields if user has permissions-debug + if has_debug_permission: + item["reason"] = row["reason"] + item["source_plugin"] = row["source_plugin"] + items.append(item) + + 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"]) + if not await self.ds.permission_allowed(request.actor, "permissions-debug"): + raise Forbidden("Permission denied") + + # Check if this is a request for JSON (has .json extension) + as_format = request.url_vars.get("format") + + if not as_format: + # Render the HTML form (even if query parameters are present) + return await self.render( + ["debug_rules.html"], + request, + { + "sorted_permissions": sorted(self.ds.permissions.keys()), + }, + ) + + # JSON API - action parameter is required + 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 = request.actor if isinstance(request.actor, dict) 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, action) + await self.ds.refresh_schemas() + db = self.ds.get_internal_database() + + count_query = f""" + WITH rules AS ( + {union_sql} + ) + SELECT COUNT(*) AS count + FROM rules + """ + 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 + ORDER BY allow DESC, (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), + "allow": row["allow"], + "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 or {}).get("id") if actor else None, + "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): + # Check if user has permissions-debug (to show sensitive fields) + has_debug_permission = await self.ds.permission_allowed( + request.actor, "permissions-debug" + ) + + # Check if this is a request for JSON (has .json extension) + as_format = request.url_vars.get("format") + + if not as_format: + # Render the HTML form (even if query parameters are present) + return await self.render( + ["debug_check.html"], + request, + { + "sorted_permissions": sorted(self.ds.permissions.keys()), + }, + ) + + # JSON API - action parameter is required + 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["used_default"] = info.get("used_default") + response["depth"] = info.get("depth") + # Only include sensitive fields if user has permissions-debug + if has_debug_permission: + response["reason"] = info.get("reason") + response["source_plugin"] = info.get("source_plugin") + + return Response.json(response) + + class AllowDebugView(BaseView): name = "allow_debug" has_json_alternate = False diff --git a/docs/authentication.rst b/docs/authentication.rst index 0343dc94..d16a7230 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1050,6 +1050,62 @@ It also provides an interface for running hypothetical permission checks against This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system. +.. _AllowedResourcesView: + +Allowed resources view +====================== + +The ``/-/allowed`` endpoint displays resources that the current actor can access for a supplied ``action`` query string argument. + +This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead. + +Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair. + +This endpoint is publicly accessible to help users understand their own permissions. However, potentially sensitive fields (``reason`` and ``source_plugin``) are only included in responses for users with the ``permissions-debug`` permission. + +Datasette includes helper endpoints for exploring the action-based permission resolver: + +``/-/allowed`` + Returns a paginated list of resources that the current actor is allowed to access for a given action. Pass ``?action=view-table`` (or another action) to select the action, and optional ``parent=``/``child=`` query parameters to narrow the results to a specific database/table pair. + +``/-/rules`` + Lists the raw permission rules (both allow and deny) contributing to each resource for the supplied action. This includes configuration-derived and plugin-provided rules. **Requires the permissions-debug permission** (only available to the root user by default). + +``/-/check`` + Evaluates whether the current actor can perform ``action`` against an optional ``parent``/``child`` resource tuple, returning the winning rule and reason. + +These endpoints work in conjunction with :ref:`plugin_hook_permission_resources_sql` and make it easier to verify that configuration allow blocks and plugins are behaving as intended. + +All three endpoints support both HTML and JSON responses. Visit the endpoint directly for an interactive HTML form interface, or add ``.json`` to the URL for a raw JSON response. + +**Security note:** The ``/-/check`` and ``/-/allowed`` endpoints are publicly accessible to help users understand their own permissions. However, potentially sensitive fields (``reason`` and ``source_plugin``) are only included in responses for users with the ``permissions-debug`` permission. The ``/-/rules`` endpoint requires the ``permissions-debug`` permission for all access. + +.. _PermissionRulesView: + +Permission rules view +====================== + +The ``/-/rules`` endpoint displays all permission rules (both allow and deny) for each candidate resource for the requested action. + +This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead. + +Pass ``?action=`` as a query parameter to specify which action to check. + +**Requires the permissions-debug permission** - this endpoint returns a 403 Forbidden error for users without this permission. The :ref:`root user ` has this permission by default. + +.. _PermissionCheckView: + +Permission check view +====================== + +The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information. + +This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead. + +Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. + +This endpoint is publicly accessible to help users understand their own permissions. However, potentially sensitive fields (``reason`` and ``source_plugin``) are only included in responses for users with the ``permissions-debug`` permission. + .. _authentication_ds_actor: The ds_actor cookie diff --git a/docs/contributing.rst b/docs/contributing.rst index c1268321..b4aab6ed 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -13,6 +13,7 @@ General guidelines * **main should always be releasable**. Incomplete features should live in branches. This ensures that any small bug fixes can be quickly released. * **The ideal commit** should bundle together the implementation, unit tests and associated documentation updates. The commit message should link to an associated issue. * **New plugin hooks** should only be shipped if accompanied by a separate release of a non-demo plugin that uses them. +* **New user-facing views and documentation** should be added or updated alongside their implementation. The `/docs` folder includes pages for plugin hooks and built-in views—please ensure any new hooks or views are reflected there so the documentation tests continue to pass. .. _devenvironment: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5b3baf3f..244f448d 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1290,12 +1290,13 @@ Here's an example that allows users to view the ``admin_log`` table only if thei if not actor: return False user_id = actor["id"] - return await datasette.get_database( + result = await datasette.get_database( "staff" ).execute( "select count(*) from admin_users where user_id = :user_id", {"user_id": user_id}, ) + return result.first()[0] > 0 return inner @@ -1303,6 +1304,184 @@ See :ref:`built-in permissions ` for a full list of permissions tha Example: `datasette-permissions-sql `_ +.. _plugin_hook_permission_resources_sql: + +permission_resources_sql(datasette, actor, action) +------------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + Access to the Datasette instance. + +``actor`` - dictionary or None + The current actor dictionary. ``None`` for anonymous requests. + +``action`` - string + The permission action being evaluated. Examples include ``"view-table"`` or ``"insert-row"``. + +Return value + A :class:`datasette.utils.permissions.PluginSQL` object, ``None`` or an iterable of ``PluginSQL`` objects. + +Datasette's action-based permission resolver calls this hook to gather SQL rows describing which +resources an actor may access (``allow = 1``) or should be denied (``allow = 0``) for a specific action. +Each SQL snippet should return ``parent``, ``child``, ``allow`` and ``reason`` columns. Any bound parameters +supplied via ``PluginSQL.params`` are automatically namespaced per plugin. + + +Permission plugin examples +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These snippets show how to use the new ``permission_resources_sql`` hook to +contribute rows to the action-based permission resolver. Each hook receives the +current actor dictionary (or ``None``) and must return ``None`` or an instance or list of +``datasette.utils.permissions.PluginSQL`` (or a coroutine that resolves to that). + +Allow Alice to view a specific table +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This plugin grants the actor with ``id == "alice"`` permission to perform the +``view-table`` action against the ``sales`` table inside the ``accounting`` database. + +.. code-block:: python + + from datasette import hookimpl + from datasette.utils.permissions import PluginSQL + + + @hookimpl + def permission_resources_sql(datasette, actor, action): + if action != "view-table": + return None + if not actor or actor.get("id") != "alice": + return None + + return PluginSQL( + source="alice_sales_allow", + sql=""" + SELECT + 'accounting' AS parent, + 'sales' AS child, + 1 AS allow, + 'alice can view accounting/sales' AS reason + """, + params={}, + ) + +Restrict execute-sql to a database prefix +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Only allow ``execute-sql`` against databases whose name begins with +``analytics_``. This shows how to use parameters that the permission resolver +will pass through to the SQL snippet. + +.. code-block:: python + + from datasette import hookimpl + from datasette.utils.permissions import PluginSQL + + + @hookimpl + def permission_resources_sql(datasette, actor, action): + if action != "execute-sql": + return None + + return PluginSQL( + source="analytics_execute_sql", + sql=""" + SELECT + parent, + NULL AS child, + 1 AS allow, + 'execute-sql allowed for analytics_*' AS reason + FROM catalog_databases + WHERE database_name LIKE :prefix + """, + params={ + "prefix": "analytics_%", + }, + ) + +Read permissions from a custom table +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This example stores grants in an internal table called ``permission_grants`` +with columns ``(actor_id, action, parent, child, allow, reason)``. + +.. code-block:: python + + from datasette import hookimpl + from datasette.utils.permissions import PluginSQL + + + @hookimpl + def permission_resources_sql(datasette, actor, action): + if not actor: + return None + + return PluginSQL( + source="permission_grants_table", + sql=""" + SELECT + parent, + child, + allow, + COALESCE(reason, 'permission_grants table') AS reason + FROM permission_grants + WHERE actor_id = :actor_id + AND action = :action + """, + params={ + "actor_id": actor.get("id"), + "action": action, + }, + ) + +Default deny with an exception +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Combine a root-level deny with a specific table allow for trusted users. +The resolver will automatically apply the most specific rule. + +.. code-block:: python + + from datasette import hookimpl + from datasette.utils.permissions import PluginSQL + + + TRUSTED = {"alice", "bob"} + + + @hookimpl + def permission_resources_sql(datasette, actor, action): + if action != "view-table": + return None + + actor_id = (actor or {}).get("id") + + if actor_id not in TRUSTED: + return PluginSQL( + source="view_table_root_deny", + sql=""" + SELECT NULL AS parent, NULL AS child, 0 AS allow, + 'default deny view-table' AS reason + """, + params={}, + ) + + return PluginSQL( + source="trusted_allow", + sql=""" + SELECT NULL AS parent, NULL AS child, 0 AS allow, + 'default deny view-table' AS reason + UNION ALL + SELECT 'reports' AS parent, 'daily_metrics' AS child, 1 AS allow, + 'trusted user access' AS reason + """, + params={"actor_id": actor_id}, + ) + +The ``UNION ALL`` ensures the deny rule is always present, while the second row +adds the exception for trusted users. + .. _plugin_hook_register_magic_parameters: register_magic_parameters(datasette) diff --git a/docs/plugins.rst b/docs/plugins.rst index 03ddf8f0..4bc1b2a9 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -224,6 +224,7 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "hooks": [ "actor_from_request", "permission_allowed", + "permission_resources_sql", "register_permissions", "skip_csrf" ] diff --git a/pytest.ini b/pytest.ini index 9f2caac0..29b84ea5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,5 +6,4 @@ filterwarnings= ignore:Using or importing the ABCs::bs4.element markers = serial: tests to avoid using with pytest-xdist -asyncio_mode = strict -asyncio_default_fixture_loop_scope = function \ No newline at end of file +asyncio_mode = strict \ No newline at end of file diff --git a/setup.py b/setup.py index f68e621e..214ce36e 100644 --- a/setup.py +++ b/setup.py @@ -80,7 +80,7 @@ setup( "test": [ "pytest>=5.2.2", "pytest-xdist>=2.2.1", - "pytest-asyncio>=0.17", + "pytest-asyncio>=1.2.0", "beautifulsoup4>=4.8.1", "black==25.1.0", "blacken-docs==1.19.1", diff --git a/tests/test_config_permission_rules.py b/tests/test_config_permission_rules.py new file mode 100644 index 00000000..aeebcc29 --- /dev/null +++ b/tests/test_config_permission_rules.py @@ -0,0 +1,118 @@ +import pytest + +from datasette.app import Datasette +from datasette.database import Database + + +async def setup_datasette(config=None, databases=None): + ds = Datasette(memory=True, config=config) + for name in databases or []: + ds.add_database(Database(ds, memory_name=f"{name}_memory"), name=name) + await ds.invoke_startup() + await ds.refresh_schemas() + return ds + + +@pytest.mark.asyncio +async def test_root_permissions_allow(): + config = {"permissions": {"execute-sql": {"id": "alice"}}} + ds = await setup_datasette(config=config, databases=["content"]) + + assert await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content") + assert not await ds.permission_allowed_2({"id": "bob"}, "execute-sql", "content") + + +@pytest.mark.asyncio +async def test_database_permission(): + config = { + "databases": { + "content": { + "permissions": { + "insert-row": {"id": "alice"}, + } + } + } + } + ds = await setup_datasette(config=config, databases=["content"]) + + assert await ds.permission_allowed_2( + {"id": "alice"}, "insert-row", ("content", "repos") + ) + assert not await ds.permission_allowed_2( + {"id": "bob"}, "insert-row", ("content", "repos") + ) + + +@pytest.mark.asyncio +async def test_table_permission(): + config = { + "databases": { + "content": { + "tables": {"repos": {"permissions": {"delete-row": {"id": "alice"}}}} + } + } + } + ds = await setup_datasette(config=config, databases=["content"]) + + assert await ds.permission_allowed_2( + {"id": "alice"}, "delete-row", ("content", "repos") + ) + assert not await ds.permission_allowed_2( + {"id": "bob"}, "delete-row", ("content", "repos") + ) + + +@pytest.mark.asyncio +async def test_view_table_allow_block(): + config = { + "databases": {"content": {"tables": {"repos": {"allow": {"id": "alice"}}}}} + } + ds = await setup_datasette(config=config, databases=["content"]) + + assert await ds.permission_allowed_2( + {"id": "alice"}, "view-table", ("content", "repos") + ) + assert not await ds.permission_allowed_2( + {"id": "bob"}, "view-table", ("content", "repos") + ) + assert await ds.permission_allowed_2( + {"id": "bob"}, "view-table", ("content", "other") + ) + + +@pytest.mark.asyncio +async def test_view_table_allow_false_blocks(): + config = {"databases": {"content": {"tables": {"repos": {"allow": False}}}}} + ds = await setup_datasette(config=config, databases=["content"]) + + assert not await ds.permission_allowed_2( + {"id": "alice"}, "view-table", ("content", "repos") + ) + + +@pytest.mark.asyncio +async def test_allow_sql_blocks(): + config = {"allow_sql": {"id": "alice"}} + ds = await setup_datasette(config=config, databases=["content"]) + + assert await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content") + assert not await ds.permission_allowed_2({"id": "bob"}, "execute-sql", "content") + + config = {"databases": {"content": {"allow_sql": {"id": "bob"}}}} + ds = await setup_datasette(config=config, databases=["content"]) + + assert await ds.permission_allowed_2({"id": "bob"}, "execute-sql", "content") + assert not await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content") + + config = {"allow_sql": False} + ds = await setup_datasette(config=config, databases=["content"]) + assert not await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content") + + +@pytest.mark.asyncio +async def test_view_instance_allow_block(): + config = {"allow": {"id": "alice"}} + ds = await setup_datasette(config=config) + + assert await ds.permission_allowed_2({"id": "alice"}, "view-instance") + assert not await ds.permission_allowed_2({"id": "bob"}, "view-instance") diff --git a/tests/test_permission_endpoints.py b/tests/test_permission_endpoints.py new file mode 100644 index 00000000..3952259e --- /dev/null +++ b/tests/test_permission_endpoints.py @@ -0,0 +1,495 @@ +""" +Tests for permission inspection endpoints: +- /-/check.json +- /-/allowed.json +- /-/rules.json +""" + +import pytest +import pytest_asyncio +from datasette.app import Datasette + + +@pytest_asyncio.fixture +async def ds_with_permissions(): + """Create a Datasette instance with some permission rules configured.""" + ds = Datasette( + config={ + "databases": { + "content": { + "allow": {"id": "*"}, # Allow all authenticated users + "tables": { + "articles": { + "allow": {"id": "editor"}, # Only editor can view + } + }, + }, + "private": { + "allow": False, # Deny everyone + }, + } + } + ) + await ds.invoke_startup() + # Add some test databases + ds.add_memory_database("content") + ds.add_memory_database("private") + return ds + + +# /-/check.json tests +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_status,expected_keys", + [ + # Valid request + ( + "/-/check.json?action=view-instance", + 200, + {"action", "allowed", "resource"}, + ), + # Missing action parameter + ("/-/check.json", 400, {"error"}), + # Invalid action + ("/-/check.json?action=nonexistent", 404, {"error"}), + # With parent parameter + ( + "/-/check.json?action=view-database&parent=content", + 200, + {"action", "allowed", "resource"}, + ), + # With parent and child parameters + ( + "/-/check.json?action=view-table&parent=content&child=articles", + 200, + {"action", "allowed", "resource"}, + ), + ], +) +async def test_check_json_basic( + ds_with_permissions, path, expected_status, expected_keys +): + response = await ds_with_permissions.client.get(path) + assert response.status_code == expected_status + data = response.json() + assert expected_keys.issubset(data.keys()) + + +@pytest.mark.asyncio +async def test_check_json_response_structure(ds_with_permissions): + """Test that /-/check.json returns the expected structure.""" + response = await ds_with_permissions.client.get( + "/-/check.json?action=view-instance" + ) + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "action" in data + assert "allowed" in data + assert "resource" in data + + # Check resource structure + assert "parent" in data["resource"] + assert "child" in data["resource"] + assert "path" in data["resource"] + + # Check allowed is boolean + assert isinstance(data["allowed"], bool) + + +@pytest.mark.asyncio +async def test_check_json_redacts_sensitive_fields_without_debug_permission( + ds_with_permissions, +): + """Test that /-/check.json redacts reason and source_plugin without permissions-debug.""" + # Anonymous user should not see sensitive fields + response = await ds_with_permissions.client.get( + "/-/check.json?action=view-instance" + ) + assert response.status_code == 200 + data = response.json() + # Sensitive fields should not be present + assert "reason" not in data + assert "source_plugin" not in data + # But these non-sensitive fields should be present + assert "used_default" in data + assert "depth" in data + + +@pytest.mark.asyncio +async def test_check_json_shows_sensitive_fields_with_debug_permission( + ds_with_permissions, +): + """Test that /-/check.json shows reason and source_plugin with permissions-debug.""" + # User with permissions-debug should see sensitive fields + response = await ds_with_permissions.client.get( + "/-/check.json?action=view-instance", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == 200 + data = response.json() + # Sensitive fields should be present + assert "reason" in data + assert "source_plugin" in data + assert "used_default" in data + assert "depth" in data + + +@pytest.mark.asyncio +async def test_check_json_child_requires_parent(ds_with_permissions): + """Test that child parameter requires parent parameter.""" + response = await ds_with_permissions.client.get( + "/-/check.json?action=view-table&child=articles" + ) + assert response.status_code == 400 + data = response.json() + assert "error" in data + assert "parent" in data["error"].lower() + + +# /-/allowed.json tests +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_status,expected_keys", + [ + # Valid supported actions + ( + "/-/allowed.json?action=view-instance", + 200, + {"action", "items", "total", "page"}, + ), + ( + "/-/allowed.json?action=view-database", + 200, + {"action", "items", "total", "page"}, + ), + ( + "/-/allowed.json?action=view-table", + 200, + {"action", "items", "total", "page"}, + ), + ( + "/-/allowed.json?action=execute-sql", + 200, + {"action", "items", "total", "page"}, + ), + # Missing action parameter + ("/-/allowed.json", 400, {"error"}), + # Invalid action + ("/-/allowed.json?action=nonexistent", 404, {"error"}), + # Unsupported action (valid but not in CANDIDATE_SQL) + ("/-/allowed.json?action=insert-row", 400, {"error"}), + ], +) +async def test_allowed_json_basic( + ds_with_permissions, path, expected_status, expected_keys +): + response = await ds_with_permissions.client.get(path) + assert response.status_code == expected_status + data = response.json() + assert expected_keys.issubset(data.keys()) + + +@pytest.mark.asyncio +async def test_allowed_json_response_structure(ds_with_permissions): + """Test that /-/allowed.json returns the expected structure.""" + response = await ds_with_permissions.client.get( + "/-/allowed.json?action=view-instance" + ) + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "action" in data + assert "actor_id" in data + assert "page" in data + assert "page_size" in data + assert "total" in data + assert "items" in data + + # Check items structure + assert isinstance(data["items"], list) + if data["items"]: + item = data["items"][0] + assert "parent" in item + assert "child" in item + assert "resource" in item + + +@pytest.mark.asyncio +async def test_allowed_json_redacts_sensitive_fields_without_debug_permission( + ds_with_permissions, +): + """Test that /-/allowed.json redacts reason and source_plugin without permissions-debug.""" + # Anonymous user should not see sensitive fields + response = await ds_with_permissions.client.get( + "/-/allowed.json?action=view-instance" + ) + assert response.status_code == 200 + data = response.json() + if data["items"]: + item = data["items"][0] + assert "reason" not in item + assert "source_plugin" not in item + + +@pytest.mark.asyncio +async def test_allowed_json_shows_sensitive_fields_with_debug_permission( + ds_with_permissions, +): + """Test that /-/allowed.json shows reason and source_plugin with permissions-debug.""" + # User with permissions-debug should see sensitive fields + response = await ds_with_permissions.client.get( + "/-/allowed.json?action=view-instance", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == 200 + data = response.json() + if data["items"]: + item = data["items"][0] + assert "reason" in item + assert "source_plugin" in item + + +@pytest.mark.asyncio +async def test_allowed_json_only_shows_allowed_resources(ds_with_permissions): + """Test that /-/allowed.json only shows resources with allow=1.""" + response = await ds_with_permissions.client.get( + "/-/allowed.json?action=view-instance" + ) + assert response.status_code == 200 + data = response.json() + + # All items should have allow implicitly set to 1 (not in response but verified by the endpoint logic) + # The endpoint filters to only show allowed resources + assert isinstance(data["items"], list) + assert data["total"] >= 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "page,page_size", + [ + (1, 10), + (2, 50), + (1, 200), # max page size + ], +) +async def test_allowed_json_pagination(ds_with_permissions, page, page_size): + """Test pagination parameters.""" + response = await ds_with_permissions.client.get( + f"/-/allowed.json?action=view-instance&page={page}&page_size={page_size}" + ) + assert response.status_code == 200 + data = response.json() + assert data["page"] == page + assert data["page_size"] == min(page_size, 200) # Capped at 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "params,expected_status", + [ + ("page=0", 400), # page must be >= 1 + ("page=-1", 400), + ("page_size=0", 400), # page_size must be >= 1 + ("page_size=-1", 400), + ("page=abc", 400), # page must be integer + ("page_size=xyz", 400), # page_size must be integer + ], +) +async def test_allowed_json_pagination_errors( + ds_with_permissions, params, expected_status +): + """Test pagination error handling.""" + response = await ds_with_permissions.client.get( + f"/-/allowed.json?action=view-instance&{params}" + ) + assert response.status_code == expected_status + + +# /-/rules.json tests +@pytest.mark.asyncio +async def test_rules_json_requires_permissions_debug(ds_with_permissions): + """Test that /-/rules.json requires permissions-debug permission.""" + # Anonymous user should be denied + response = await ds_with_permissions.client.get( + "/-/rules.json?action=view-instance" + ) + assert response.status_code == 403 + + # Regular authenticated user should also be denied + response = await ds_with_permissions.client.get( + "/-/rules.json?action=view-instance", + cookies={ + "ds_actor": ds_with_permissions.client.actor_cookie({"id": "regular-user"}) + }, + ) + assert response.status_code == 403 + + # User with permissions-debug should be allowed + response = await ds_with_permissions.client.get( + "/-/rules.json?action=view-instance", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_status,expected_keys", + [ + # Valid request + ( + "/-/rules.json?action=view-instance", + 200, + {"action", "items", "total", "page"}, + ), + ( + "/-/rules.json?action=view-database", + 200, + {"action", "items", "total", "page"}, + ), + # Missing action parameter + ("/-/rules.json", 400, {"error"}), + # Invalid action + ("/-/rules.json?action=nonexistent", 404, {"error"}), + ], +) +async def test_rules_json_basic( + ds_with_permissions, path, expected_status, expected_keys +): + # Use debugger user who has permissions-debug + response = await ds_with_permissions.client.get( + path, + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == expected_status + data = response.json() + assert expected_keys.issubset(data.keys()) + + +@pytest.mark.asyncio +async def test_rules_json_response_structure(ds_with_permissions): + """Test that /-/rules.json returns the expected structure.""" + response = await ds_with_permissions.client.get( + "/-/rules.json?action=view-instance", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "action" in data + assert "actor_id" in data + assert "page" in data + assert "page_size" in data + assert "total" in data + assert "items" in data + + # Check items structure + assert isinstance(data["items"], list) + if data["items"]: + item = data["items"][0] + assert "parent" in item + assert "child" in item + assert "resource" in item + assert "allow" in item # Important: should include allow field + assert "reason" in item + assert "source_plugin" in item + + +@pytest.mark.asyncio +async def test_rules_json_includes_both_allow_and_deny(ds_with_permissions): + """Test that /-/rules.json includes both allow and deny rules.""" + response = await ds_with_permissions.client.get( + "/-/rules.json?action=view-database", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == 200 + data = response.json() + + # Check that items have the allow field + assert isinstance(data["items"], list) + if data["items"]: + # Verify allow field exists and is 0 or 1 + for item in data["items"]: + assert "allow" in item + assert item["allow"] in (0, 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "page,page_size", + [ + (1, 10), + (2, 50), + (1, 200), # max page size + ], +) +async def test_rules_json_pagination(ds_with_permissions, page, page_size): + """Test pagination parameters.""" + response = await ds_with_permissions.client.get( + f"/-/rules.json?action=view-instance&page={page}&page_size={page_size}", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == 200 + data = response.json() + assert data["page"] == page + assert data["page_size"] == min(page_size, 200) # Capped at 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "params,expected_status", + [ + ("page=0", 400), # page must be >= 1 + ("page=-1", 400), + ("page_size=0", 400), # page_size must be >= 1 + ("page_size=-1", 400), + ("page=abc", 400), # page must be integer + ("page_size=xyz", 400), # page_size must be integer + ], +) +async def test_rules_json_pagination_errors( + ds_with_permissions, params, expected_status +): + """Test pagination error handling.""" + response = await ds_with_permissions.client.get( + f"/-/rules.json?action=view-instance&{params}", + cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})}, + ) + assert response.status_code == expected_status + + +# Test that HTML endpoints return HTML (not JSON) when accessed without .json +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,needs_debug", + [ + ("/-/check", False), + ("/-/check?action=view-instance", False), + ("/-/allowed", False), + ("/-/allowed?action=view-instance", False), + ("/-/rules", True), + ("/-/rules?action=view-instance", True), + ], +) +async def test_html_endpoints_return_html(ds_with_permissions, path, needs_debug): + """Test that endpoints without .json extension return HTML.""" + if needs_debug: + # Rules endpoint requires permissions-debug + response = await ds_with_permissions.client.get( + path, + cookies={ + "ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"}) + }, + ) + else: + response = await ds_with_permissions.client.get(path) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + # Check for HTML structure + text = response.text + assert "" in text or " PluginProvider: + def provider(action: str) -> PluginSQL: + return PluginSQL( + "allow_all", + """ + SELECT NULL AS parent, NULL AS child, 1 AS allow, + 'global allow for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"user": user, "action": action}, + ) + + return provider + + +def plugin_deny_specific_table(user: str, parent: str, child: str) -> PluginProvider: + def provider(action: str) -> PluginSQL: + return PluginSQL( + "deny_specific_table", + """ + SELECT :parent AS parent, :child AS child, 0 AS allow, + 'deny ' || :parent || '/' || :child || ' for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"parent": parent, "child": child, "user": user, "action": action}, + ) + + return provider + + +def plugin_org_policy_deny_parent(parent: str) -> PluginProvider: + def provider(action: str) -> PluginSQL: + return PluginSQL( + "org_policy_parent_deny", + """ + SELECT :parent AS parent, NULL AS child, 0 AS allow, + 'org policy: parent ' || :parent || ' denied on ' || :action AS reason + """, + {"parent": parent, "action": action}, + ) + + return provider + + +def plugin_allow_parent_for_user(user: str, parent: str) -> PluginProvider: + def provider(action: str) -> PluginSQL: + return PluginSQL( + "allow_parent", + """ + SELECT :parent AS parent, NULL AS child, 1 AS allow, + 'allow full parent for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"parent": parent, "user": user, "action": action}, + ) + + return provider + + +def plugin_child_allow_for_user(user: str, parent: str, child: str) -> PluginProvider: + def provider(action: str) -> PluginSQL: + return PluginSQL( + "allow_child", + """ + SELECT :parent AS parent, :child AS child, 1 AS allow, + 'allow child for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"parent": parent, "child": child, "user": user, "action": action}, + ) + + return provider + + +def plugin_root_deny_for_all() -> PluginProvider: + def provider(action: str) -> PluginSQL: + return PluginSQL( + "root_deny", + """ + SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :action AS reason + """, + {"action": action}, + ) + + return provider + + +def plugin_conflicting_same_child_rules( + user: str, parent: str, child: str +) -> List[PluginProvider]: + def allow_provider(action: str) -> PluginSQL: + return PluginSQL( + "conflict_child_allow", + """ + SELECT :parent AS parent, :child AS child, 1 AS allow, + 'team grant at child for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"parent": parent, "child": child, "user": user, "action": action}, + ) + + def deny_provider(action: str) -> PluginSQL: + return PluginSQL( + "conflict_child_deny", + """ + SELECT :parent AS parent, :child AS child, 0 AS allow, + 'exception deny at child for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"parent": parent, "child": child, "user": user, "action": action}, + ) + + return [allow_provider, deny_provider] + + +def plugin_allow_all_for_action(user: str, allowed_action: str) -> PluginProvider: + def provider(action: str) -> PluginSQL: + if action != allowed_action: + return PluginSQL( + f"allow_all_{allowed_action}_noop", + NO_RULES_SQL, + {}, + ) + return PluginSQL( + f"allow_all_{allowed_action}", + """ + SELECT NULL AS parent, NULL AS child, 1 AS allow, + 'global allow for ' || :user || ' on ' || :action AS reason + WHERE :actor = :user + """, + {"user": user, "action": action}, + ) + + return provider + + +VIEW_TABLE = "view-table" + + +# ---------- Catalog DDL (from your schema) ---------- +CATALOG_DDL = """ +CREATE TABLE IF NOT EXISTS catalog_databases ( + database_name TEXT PRIMARY KEY, + path TEXT, + is_memory INTEGER, + schema_version INTEGER +); +CREATE TABLE IF NOT EXISTS catalog_tables ( + database_name TEXT, + table_name TEXT, + rootpage INTEGER, + sql TEXT, + PRIMARY KEY (database_name, table_name), + FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name) +); +""" + +PARENTS = ["accounting", "hr", "analytics"] +SPECIALS = {"accounting": ["sales"], "analytics": ["secret"], "hr": []} + +TABLE_CANDIDATES_SQL = ( + "SELECT database_name AS parent, table_name AS child FROM catalog_tables" +) +PARENT_CANDIDATES_SQL = ( + "SELECT database_name AS parent, NULL AS child FROM catalog_databases" +) + + +# ---------- Helpers ---------- +async def seed_catalog(db, per_parent: int = 10) -> None: + await db.execute_write_script(CATALOG_DDL) + # databases + db_rows = [(p, f"/{p}.db", 0, 1) for p in PARENTS] + await db.execute_write_many( + "INSERT OR REPLACE INTO catalog_databases(database_name, path, is_memory, schema_version) VALUES (?,?,?,?)", + db_rows, + ) + + # tables + def tables_for(parent: str, n: int): + base = [f"table{i:02d}" for i in range(1, n + 1)] + for s in SPECIALS.get(parent, []): + if s not in base: + base[0] = s + return base + + table_rows = [] + for p in PARENTS: + for t in tables_for(p, per_parent): + table_rows.append((p, t, 0, f"CREATE TABLE {t} (id INTEGER PRIMARY KEY)")) + await db.execute_write_many( + "INSERT OR REPLACE INTO catalog_tables(database_name, table_name, rootpage, sql) VALUES (?,?,?,?)", + table_rows, + ) + + +def res_allowed(rows, parent=None): + return sorted( + r["resource"] + for r in rows + if r["allow"] == 1 and (parent is None or r["parent"] == parent) + ) + + +def res_denied(rows, parent=None): + return sorted( + r["resource"] + for r in rows + if r["allow"] == 0 and (parent is None or r["parent"] == parent) + ) + + +# ---------- Tests ---------- +@pytest.mark.asyncio +async def test_alice_global_allow_with_specific_denies_catalog(db): + await seed_catalog(db) + plugins = [ + plugin_allow_all_for_user("alice"), + plugin_deny_specific_table("alice", "accounting", "sales"), + plugin_org_policy_deny_parent("hr"), + ] + rows = await resolve_permissions_from_catalog( + db, "alice", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True + ) + # Alice can see everything except accounting/sales and hr/* + assert "/accounting/sales" in res_denied(rows) + for r in rows: + if r["parent"] == "hr": + assert r["allow"] == 0 + elif r["resource"] == "/accounting/sales": + assert r["allow"] == 0 + else: + assert r["allow"] == 1 + + +@pytest.mark.asyncio +async def test_carol_parent_allow_but_child_conflict_deny_wins_catalog(db): + await seed_catalog(db) + plugins = [ + plugin_org_policy_deny_parent("hr"), + plugin_allow_parent_for_user("carol", "analytics"), + *plugin_conflicting_same_child_rules("carol", "analytics", "secret"), + ] + rows = await resolve_permissions_from_catalog( + db, "carol", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True + ) + allowed_analytics = res_allowed(rows, parent="analytics") + denied_analytics = res_denied(rows, parent="analytics") + + assert "/analytics/secret" in denied_analytics + # 10 analytics children total, 1 denied + assert len(allowed_analytics) == 9 + + +@pytest.mark.asyncio +async def test_specificity_child_allow_overrides_parent_deny_catalog(db): + await seed_catalog(db) + plugins = [ + plugin_allow_all_for_user("alice"), + plugin_org_policy_deny_parent("analytics"), # parent-level deny + plugin_child_allow_for_user( + "alice", "analytics", "table02" + ), # child allow beats parent deny + ] + rows = await resolve_permissions_from_catalog( + db, "alice", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True + ) + + # table02 allowed, other analytics tables denied + assert any(r["resource"] == "/analytics/table02" and r["allow"] == 1 for r in rows) + assert all( + (r["parent"] != "analytics" or r["child"] == "table02" or r["allow"] == 0) + for r in rows + ) + + +@pytest.mark.asyncio +async def test_root_deny_all_but_parent_allow_rescues_specific_parent_catalog(db): + await seed_catalog(db) + plugins = [ + plugin_root_deny_for_all(), # root deny + plugin_allow_parent_for_user( + "bob", "accounting" + ), # parent allow (more specific) + ] + rows = await resolve_permissions_from_catalog( + db, "bob", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True + ) + for r in rows: + if r["parent"] == "accounting": + assert r["allow"] == 1 + else: + assert r["allow"] == 0 + + +@pytest.mark.asyncio +async def test_parent_scoped_candidates(db): + await seed_catalog(db) + plugins = [ + plugin_org_policy_deny_parent("hr"), + plugin_allow_parent_for_user("carol", "analytics"), + ] + rows = await resolve_permissions_from_catalog( + db, "carol", plugins, VIEW_TABLE, PARENT_CANDIDATES_SQL, implicit_deny=True + ) + d = {r["resource"]: r["allow"] for r in rows} + assert d["/analytics"] == 1 + assert d["/hr"] == 0 + + +@pytest.mark.asyncio +async def test_implicit_deny_behavior(db): + await seed_catalog(db) + plugins = [] # no rules at all + + # implicit_deny=True -> everything denied with reason 'implicit deny' + rows = await resolve_permissions_from_catalog( + db, "erin", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True + ) + assert all(r["allow"] == 0 and r["reason"] == "implicit deny" for r in rows) + + # implicit_deny=False -> no winner => allow is None, reason is None + rows2 = await resolve_permissions_from_catalog( + db, "erin", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=False + ) + assert all(r["allow"] is None and r["reason"] is None for r in rows2) + + +@pytest.mark.asyncio +async def test_candidate_filters_via_params(db): + await seed_catalog(db) + # Add some metadata to test filtering + # Mark 'hr' as is_memory=1 and increment analytics schema_version + await db.execute_write( + "UPDATE catalog_databases SET is_memory=1 WHERE database_name='hr'" + ) + await db.execute_write( + "UPDATE catalog_databases SET schema_version=2 WHERE database_name='analytics'" + ) + + # Candidate SQL that filters by db metadata via params + candidate_sql = """ + SELECT t.database_name AS parent, t.table_name AS child + FROM catalog_tables t + JOIN catalog_databases d ON d.database_name = t.database_name + WHERE (:exclude_memory = 1 AND d.is_memory = 1) IS NOT 1 + AND (:min_schema_version IS NULL OR d.schema_version >= :min_schema_version) + """ + + plugins = [ + plugin_root_deny_for_all(), + plugin_allow_parent_for_user( + "dev", "analytics" + ), # analytics rescued if included by candidates + ] + + # Case 1: exclude memory dbs, require schema_version >= 2 -> only analytics appear, and thus are allowed + rows = await resolve_permissions_from_catalog( + db, + "dev", + plugins, + VIEW_TABLE, + candidate_sql, + candidate_params={"exclude_memory": 1, "min_schema_version": 2}, + implicit_deny=True, + ) + assert rows and all(r["parent"] == "analytics" for r in rows) + assert all(r["allow"] == 1 for r in rows) + + # Case 2: include memory dbs, min_schema_version = None -> accounting/hr/analytics appear, + # but root deny wins except where specifically allowed (none except analytics parent allow doesn’t apply to table depth if candidate includes children; still fine—policy is explicit). + rows2 = await resolve_permissions_from_catalog( + db, + "dev", + plugins, + VIEW_TABLE, + candidate_sql, + candidate_params={"exclude_memory": 0, "min_schema_version": None}, + implicit_deny=True, + ) + assert any(r["parent"] == "accounting" for r in rows2) + assert any(r["parent"] == "hr" for r in rows2) + # For table-scoped candidates, the parent-level allow does not override root deny unless you have child-level rules + assert all(r["allow"] in (0, 1) for r in rows2) + + +@pytest.mark.asyncio +async def test_action_specific_rules(db): + await seed_catalog(db) + plugins = [plugin_allow_all_for_action("dana", VIEW_TABLE)] + + view_rows = await resolve_permissions_from_catalog( + db, + "dana", + plugins, + VIEW_TABLE, + TABLE_CANDIDATES_SQL, + implicit_deny=True, + ) + assert view_rows and all(r["allow"] == 1 for r in view_rows) + assert all(r["action"] == VIEW_TABLE for r in view_rows) + + insert_rows = await resolve_permissions_from_catalog( + db, + "dana", + plugins, + "insert-row", + TABLE_CANDIDATES_SQL, + implicit_deny=True, + ) + assert insert_rows and all(r["allow"] == 0 for r in insert_rows) + assert all(r["reason"] == "implicit deny" for r in insert_rows) + assert all(r["action"] == "insert-row" for r in insert_rows)