From 8ada5342674fb4778aa5076af2a2571311568121 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 8 Oct 2025 10:56:57 -0700 Subject: [PATCH 001/187] Document JSON API extras --- docs/json_api.rst | 97 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/docs/json_api.rst b/docs/json_api.rst index 3f696f39..42f10d74 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -405,6 +405,94 @@ Special table arguments ``?_nocount=1`` Disable the ``select count(*)`` query used on this page - a count of ``None`` will be returned instead. +Table extras +~~~~~~~~~~~~ + +JSON responses for table pages can include additional keys that are omitted by default. Pass one or more ``?_extra=NAME`` +parameters (either repeating the argument or providing a comma-separated list) to opt in to the data that you need. The +following extras are available: + +``?_extra=count`` + Returns the total number of rows that match the current filters, or ``null`` if the calculation times out or is + otherwise unavailable. ``count`` may be served from cached introspection data for immutable databases when + possible.【F:datasette/views/table.py†L1284-L1311】 + +``?_extra=count_sql`` + Returns the SQL that Datasette will execute in order to calculate the total row count.【F:datasette/views/table.py†L1284-L1290】 + +``?_extra=facet_results`` + Includes the full set of facet results calculated for the table view. The returned object has a ``results`` mapping + of facet definitions to their buckets and a ``timed_out`` list describing any facets that hit the time limit.【F:datasette/views/table.py†L1316-L1365】 + +``?_extra=facets_timed_out`` + Adds just the list of facets that timed out while executing, without the full facet payload.【F:datasette/views/table.py†L1592-L1617】 + +``?_extra=suggested_facets`` + Returns suggestions for additional facets to apply, each with a ``name`` and ``toggle_url`` that can be used to + activate that facet.【F:datasette/views/table.py†L1367-L1386】 + +``?_extra=human_description_en`` + Adds a human-readable sentence describing the current filters and sort order.【F:datasette/views/table.py†L1388-L1403】 + +``?_extra=next_url`` + Includes an absolute URL for the next page of results, or ``null`` if there is no next page.【F:datasette/views/table.py†L1404-L1406】 + +``?_extra=columns`` + Restores the ``columns`` list to the JSON output. Datasette removes this list by default to avoid duplicating + information unless it is explicitly requested using this extra.【F:datasette/renderer.py†L110-L123】 + +``?_extra=primary_keys`` + Adds the list of primary key columns for the table.【F:datasette/views/table.py†L1408-L1414】 + +``?_extra=query`` + Returns the SQL query and parameters used to produce the current page of results.【F:datasette/views/table.py†L1484-L1490】 + +``?_extra=metadata`` + Includes metadata for the table and its columns, combining values from configuration and the ``metadata_columns`` + table.【F:datasette/views/table.py†L1491-L1527】 + +``?_extra=database`` and ``?_extra=table`` + Return the database name and table name for the current view.【F:datasette/views/table.py†L1510-L1517】 + +``?_extra=database_color`` + Adds the configured color for the database, useful for mirroring Datasette's UI styling.【F:datasette/views/table.py†L1518-L1520】 + +``?_extra=renderers`` + Lists the alternative output renderers available for the data, mapping renderer names to URLs that apply the + requested renderer.【F:datasette/views/table.py†L1554-L1577】 + +``?_extra=custom_table_templates`` + Returns the ordered list of template names Datasette will consider when rendering the HTML table view.【F:datasette/views/table.py†L1533-L1540】 + +``?_extra=sorted_facet_results`` + Provides the facet definitions sorted by the number of results they contain, ready for display in descending order.【F:datasette/views/table.py†L1541-L1549】 + +``?_extra=table_definition`` and ``?_extra=view_definition`` + Include the ``CREATE TABLE`` or ``CREATE VIEW`` SQL definitions where available.【F:datasette/views/table.py†L1548-L1553】 + +``?_extra=is_view`` and ``?_extra=private`` + Report whether the current resource is a view and whether it is private to the current actor.【F:datasette/views/table.py†L1439-L1453】【F:datasette/views/table.py†L1581-L1587】 + +``?_extra=expandable_columns`` + Lists foreign key columns that can be expanded, each entry pairing the foreign key description with the column used + for labels when expanding that relationship.【F:datasette/views/table.py†L1584-L1588】 + +``?_extra=form_hidden_args`` + Returns the ``("key", "value")`` pairs that Datasette includes as hidden fields in table forms for the current set + of ``_`` query string arguments.【F:datasette/views/table.py†L1519-L1530】 + +``?_extra=extras`` + Provides metadata about all available extras, including toggle URLs that can be used to turn them on and off in the + current query string.【F:datasette/views/table.py†L1592-L1611】 + +``?_extra=debug`` and ``?_extra=request`` + Return debugging context, including the resolved SQL details and request metadata such as the full URL and query + string arguments.【F:datasette/views/table.py†L1442-L1467】 + +In addition to these API-friendly extras, Datasette exposes a handful of extras that are primarily intended for its HTML +interface—``actions``, ``filters``, ``display_columns`` and ``display_rows``. These currently return Python objects such +as callables or ``sqlite3.Row`` instances and may raise serialization errors if requested as JSON extras.【F:datasette/views/table.py†L1415-L1526】【F:datasette/renderer.py†L120-L123】 + .. _expand_foreign_keys: Expanding foreign key references @@ -440,6 +528,15 @@ looks like: The column in the foreign key table that is used for the label can be specified in ``metadata.json`` - see :ref:`label_columns`. +Row detail extras +----------------- + +Row detail JSON is available at ``///.json``. Responses include the database and table names, +``rows`` and ``columns`` for the matched record, the primary key column names, the primary key values, and a ``query_ms`` +timing for the lookup. Pass ``?_extras=foreign_key_tables`` (note the plural parameter name) to include a +``foreign_key_tables`` array describing incoming foreign keys, the number of related rows and navigation links to view +those rows.【F:datasette/views/row.py†L41-L111】 + .. _json_api_discover_alternate: Discovering the JSON for a page From 85da8474d45bda2f0c48c612e03ac111650ca7d6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 8 Oct 2025 13:11:32 -0700 Subject: [PATCH 002/187] Python 3.14, drop Python 3.9 Closes #2506 --- .github/workflows/deploy-branch-preview.yml | 2 +- .github/workflows/deploy-latest.yml | 6 +++--- .github/workflows/publish.yml | 10 +++++----- .github/workflows/spellcheck.yml | 2 +- .github/workflows/test-coverage.yml | 2 +- .github/workflows/test-pyodide.yml | 2 +- .github/workflows/test-sqlite-support.yml | 4 ++-- .github/workflows/test.yml | 9 +++------ docs/installation.rst | 2 +- setup.py | 8 ++++---- 10 files changed, 22 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy-branch-preview.yml b/.github/workflows/deploy-branch-preview.yml index 872aff71..e56d9c27 100644 --- a/.github/workflows/deploy-branch-preview.yml +++ b/.github/workflows/deploy-branch-preview.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.11 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install dependencies diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index f235b442..10cdac01 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -16,10 +16,10 @@ jobs: - name: Check out datasette uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v4 - # gcloud commmand breaks on higher Python versions, so stick with 3.9: + uses: actions/setup-python@v6 + # Using Python 3.10 for gcloud compatibility: with: - python-version: "3.9" + python-version: "3.10" - uses: actions/cache@v4 name: Configure pip caching with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bf67a115..5acb4899 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,11 +12,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: pip @@ -37,7 +37,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' cache: pip @@ -58,9 +58,9 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: '3.10' cache: pip cache-dependency-path: setup.py - name: Install dependencies diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index 907104b8..8a47fd2d 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.11' cache: 'pip' diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 32654a93..22a69150 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -17,7 +17,7 @@ jobs: - name: Check out datasette uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' cache: 'pip' diff --git a/.github/workflows/test-pyodide.yml b/.github/workflows/test-pyodide.yml index abfa9b90..7357b30c 100644 --- a/.github/workflows/test-pyodide.yml +++ b/.github/workflows/test-pyodide.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" cache: 'pip' diff --git a/.github/workflows/test-sqlite-support.yml b/.github/workflows/test-sqlite-support.yml index 1deef282..698aec8a 100644 --- a/.github/workflows/test-sqlite-support.yml +++ b/.github/workflows/test-sqlite-support.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: platform: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] sqlite-version: [ #"3", # latest version "3.46", @@ -27,7 +27,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 773876d3..901c4905 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -33,16 +33,13 @@ jobs: pytest -m "serial" # And the test that exceeds a localhost HTTPS server tests/test_datasette_https_server.sh - - name: Install docs dependencies on Python 3.9+ - if: matrix.python-version != '3.8' + - name: Install docs dependencies run: | pip install -e '.[docs]' - name: Check if cog needs to be run - if: matrix.python-version != '3.8' run: | cog --check docs/*.rst - name: Check if blacken-docs needs to be run - if: matrix.python-version != '3.8' run: | # This fails on syntax errors, or a diff was applied blacken-docs -l 60 docs/*.rst diff --git a/docs/installation.rst b/docs/installation.rst index e272241b..33d3d6a1 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -54,7 +54,7 @@ If the latest packaged release of Datasette has not yet been made available thro Using pip --------- -Datasette requires Python 3.8 or higher. The `Python.org Python For Beginners `__ page has instructions for getting started. +Datasette requires Python 3.10 or higher. The `Python.org Python For Beginners `__ page has instructions for getting started. You can install Datasette and its dependencies using ``pip``:: diff --git a/setup.py b/setup.py index 48b32692..f68e621e 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup( packages=find_packages(exclude=("tests",)), package_data={"datasette": ["templates/*.html"]}, include_package_data=True, - python_requires=">=3.8", + python_requires=">=3.10", install_requires=[ "asgiref>=3.2.10", "click>=7.1.1", @@ -48,7 +48,6 @@ setup( "Jinja2>=2.10.3", "hupper>=1.9", "httpx>=0.20", - 'importlib_resources>=1.3.1; python_version < "3.9"', 'importlib_metadata>=4.6; python_version < "3.10"', "pluggy>=1.0", "uvicorn>=0.11", @@ -99,9 +98,10 @@ setup( "Intended Audience :: End Users/Desktop", "Topic :: Database", "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.8", ], ) From 27084caa04016f9801900b91ff9912ffa1063398 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 8 Oct 2025 14:27:51 -0700 Subject: [PATCH 003/187] 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 --- datasette/app.py | 160 ++++++ datasette/default_permissions.py | 161 +++++- datasette/hookspecs.py | 12 + .../templates/_permission_ui_styles.html | 145 +++++ datasette/templates/debug_allowed.html | 294 +++++++++++ datasette/templates/debug_check.html | 350 +++++++++++++ datasette/templates/debug_rules.html | 268 ++++++++++ datasette/utils/permissions.py | 244 +++++++++ datasette/views/base.py | 19 + datasette/views/special.py | 432 ++++++++++++++- docs/authentication.rst | 56 ++ docs/contributing.rst | 1 + docs/plugin_hooks.rst | 181 ++++++- docs/plugins.rst | 1 + pytest.ini | 3 +- setup.py | 2 +- tests/test_config_permission_rules.py | 118 +++++ tests/test_permission_endpoints.py | 495 ++++++++++++++++++ tests/test_plugins.py | 26 +- tests/test_utils_permissions.py | 440 ++++++++++++++++ 20 files changed, 3381 insertions(+), 27 deletions(-) create mode 100644 datasette/templates/_permission_ui_styles.html create mode 100644 datasette/templates/debug_allowed.html create mode 100644 datasette/templates/debug_check.html create mode 100644 datasette/templates/debug_rules.html create mode 100644 datasette/utils/permissions.py create mode 100644 tests/test_config_permission_rules.py create mode 100644 tests/test_permission_endpoints.py create mode 100644 tests/test_utils_permissions.py 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) From e2a739c4965b520e994aeabebcc9a83d3079d94b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 8 Oct 2025 20:32:16 -0700 Subject: [PATCH 004/187] Fix for asyncio.iscoroutinefunction deprecation warnings Closes #2512 Refs https://github.com/simonw/asyncinject/issues/18 --- datasette/utils/check_callable.py | 6 +++--- setup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/datasette/utils/check_callable.py b/datasette/utils/check_callable.py index 5b8a30ac..a0997d20 100644 --- a/datasette/utils/check_callable.py +++ b/datasette/utils/check_callable.py @@ -1,4 +1,4 @@ -import asyncio +import inspect import types from typing import NamedTuple, Any @@ -17,9 +17,9 @@ def check_callable(obj: Any) -> CallableStatus: return CallableStatus(True, False) if isinstance(obj, types.FunctionType): - return CallableStatus(True, asyncio.iscoroutinefunction(obj)) + return CallableStatus(True, inspect.iscoroutinefunction(obj)) if hasattr(obj, "__call__"): - return CallableStatus(True, asyncio.iscoroutinefunction(obj.__call__)) + return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__)) assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj)) diff --git a/setup.py b/setup.py index 214ce36e..fa5be8e5 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ setup( "mergedeep>=1.1.1", "itsdangerous>=1.1", "sqlite-utils>=3.30", - "asyncinject>=0.5", + "asyncinject>=0.6.1", "setuptools", "pip", ], From 659673614a917f298748020ed8efafc162d84985 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 8 Oct 2025 21:53:34 -0700 Subject: [PATCH 005/187] Refactor debug templates to use shared JavaScript functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted common JavaScript utilities from debug_allowed.html, debug_check.html, and debug_rules.html into a new _debug_common_functions.html include template. This eliminates code duplication and improves maintainability. The shared functions include: - populateFormFromURL(): Populates form fields from URL query parameters - updateURL(formId, page): Updates browser URL with form values - escapeHtml(text): HTML escaping utility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../templates/_debug_common_functions.html | 70 ++++++++++++++++ datasette/templates/debug_allowed.html | 81 +++++-------------- datasette/templates/debug_check.html | 57 ++++--------- datasette/templates/debug_rules.html | 70 +++++----------- 4 files changed, 120 insertions(+), 158 deletions(-) create mode 100644 datasette/templates/_debug_common_functions.html diff --git a/datasette/templates/_debug_common_functions.html b/datasette/templates/_debug_common_functions.html new file mode 100644 index 00000000..6dd5a9d9 --- /dev/null +++ b/datasette/templates/_debug_common_functions.html @@ -0,0 +1,70 @@ + diff --git a/datasette/templates/debug_allowed.html b/datasette/templates/debug_allowed.html index 5f22b6a4..031ff07d 100644 --- a/datasette/templates/debug_allowed.html +++ b/datasette/templates/debug_allowed.html @@ -5,6 +5,7 @@ {% block extra_head %} {% include "_permission_ui_styles.html" %} +{% include "_debug_common_functions.html" %} {% endblock %} {% block content %} @@ -81,70 +82,31 @@ const pagination = document.getElementById('pagination'); const submitBtn = document.getElementById('submit-btn'); let currentData = null; -// Populate form from URL parameters on page load -function populateFormFromURL() { - const params = new URLSearchParams(window.location.search); - - const action = params.get('action'); - if (action) { - document.getElementById('action').value = action; - } - - const parent = params.get('parent'); - if (parent) { - document.getElementById('parent').value = parent; - } - - const child = params.get('child'); - if (child) { - document.getElementById('child').value = child; - } - - const pageSize = params.get('page_size'); - if (pageSize) { - document.getElementById('page_size').value = pageSize; - } - - const page = params.get('page'); - - // If parameters are present, automatically fetch results - if (action) { - fetchResults(page ? parseInt(page) : 1, false); - } -} - -// Update URL with current form values and page -function updateURL(page = 1) { - const formData = new FormData(form); - const params = new URLSearchParams(); - - for (const [key, value] of formData.entries()) { - if (value) { - params.append(key, value); - } - } - - if (page > 1) { - params.set('page', page.toString()); - } - - const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : ''); - window.history.pushState({}, '', newURL); -} - form.addEventListener('submit', async (ev) => { ev.preventDefault(); - updateURL(1); + updateURL('allowed-form', 1); await fetchResults(1, false); }); // Handle browser back/forward window.addEventListener('popstate', () => { - populateFormFromURL(); + const params = populateFormFromURL(); + const action = params.get('action'); + const page = params.get('page'); + if (action) { + fetchResults(page ? parseInt(page) : 1, false); + } }); // Populate form on initial load -populateFormFromURL(); +(function() { + const params = populateFormFromURL(); + const action = params.get('action'); + const page = params.get('page'); + if (action) { + fetchResults(page ? parseInt(page) : 1, false); + } +})(); async function fetchResults(page = 1, updateHistory = true) { submitBtn.disabled = true; @@ -230,7 +192,7 @@ function displayResults(data) { prevLink.textContent = '← Previous'; prevLink.addEventListener('click', (e) => { e.preventDefault(); - updateURL(data.page - 1); + updateURL('allowed-form', data.page - 1); fetchResults(data.page - 1, false); }); pagination.appendChild(prevLink); @@ -246,7 +208,7 @@ function displayResults(data) { nextLink.textContent = 'Next →'; nextLink.addEventListener('click', (e) => { e.preventDefault(); - updateURL(data.page + 1); + updateURL('allowed-form', data.page + 1); fetchResults(data.page + 1, false); }); pagination.appendChild(nextLink); @@ -272,13 +234,6 @@ function displayError(data) { resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } -function escapeHtml(text) { - if (text === null || text === undefined) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - // Disable child input if parent is empty const parentInput = document.getElementById('parent'); const childInput = document.getElementById('child'); diff --git a/datasette/templates/debug_check.html b/datasette/templates/debug_check.html index b8bbd0a6..2e077327 100644 --- a/datasette/templates/debug_check.html +++ b/datasette/templates/debug_check.html @@ -4,6 +4,7 @@ {% block extra_head %} +{% include "_debug_common_functions.html" %} + + +
+
+ +
+
+
+ Navigate + Enter Select + Esc Close +
+
+
+ `; + } + + setupEventListeners() { + const dialog = this.shadowRoot.querySelector('dialog'); + const input = this.shadowRoot.querySelector('.search-input'); + const resultsContainer = this.shadowRoot.querySelector('.results-container'); + + // Global keyboard listener for "/" + document.addEventListener('keydown', (e) => { + if (e.key === '/' && !this.isInputFocused() && !dialog.open) { + e.preventDefault(); + this.openMenu(); + } + }); + + // Input event + input.addEventListener('input', (e) => { + this.handleSearch(e.target.value); + }); + + // Keyboard navigation + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.moveSelection(1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.moveSelection(-1); + } else if (e.key === 'Enter') { + e.preventDefault(); + this.selectCurrentItem(); + } else if (e.key === 'Escape') { + this.closeMenu(); + } + }); + + // Click on result item + resultsContainer.addEventListener('click', (e) => { + const item = e.target.closest('.result-item'); + if (item) { + const index = parseInt(item.dataset.index); + this.selectItem(index); + } + }); + + // Close on backdrop click + dialog.addEventListener('click', (e) => { + if (e.target === dialog) { + this.closeMenu(); + } + }); + + // Initial load + this.loadInitialData(); + } + + isInputFocused() { + const activeElement = document.activeElement; + return activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.isContentEditable + ); + } + + loadInitialData() { + const itemsAttr = this.getAttribute('items'); + if (itemsAttr) { + try { + this.allItems = JSON.parse(itemsAttr); + this.matches = this.allItems; + } catch (e) { + console.error('Failed to parse items attribute:', e); + this.allItems = []; + this.matches = []; + } + } + } + + handleSearch(query) { + clearTimeout(this.debounceTimer); + + this.debounceTimer = setTimeout(() => { + const url = this.getAttribute('url'); + + if (url) { + // Fetch from API + this.fetchResults(url, query); + } else { + // Filter local items + this.filterLocalItems(query); + } + }, 200); + } + + async fetchResults(url, query) { + try { + const searchUrl = `${url}?q=${encodeURIComponent(query)}`; + const response = await fetch(searchUrl); + const data = await response.json(); + this.matches = data.matches || []; + this.selectedIndex = this.matches.length > 0 ? 0 : -1; + this.renderResults(); + } catch (e) { + console.error('Failed to fetch search results:', e); + this.matches = []; + this.renderResults(); + } + } + + filterLocalItems(query) { + if (!query.trim()) { + this.matches = []; + } else { + const lowerQuery = query.toLowerCase(); + this.matches = (this.allItems || []).filter(item => + item.name.toLowerCase().includes(lowerQuery) || + item.url.toLowerCase().includes(lowerQuery) + ); + } + this.selectedIndex = this.matches.length > 0 ? 0 : -1; + this.renderResults(); + } + + renderResults() { + const container = this.shadowRoot.querySelector('.results-container'); + const input = this.shadowRoot.querySelector('.search-input'); + + if (this.matches.length === 0) { + const message = input.value.trim() ? 'No results found' : 'Start typing to search...'; + container.innerHTML = `
${message}
`; + return; + } + + container.innerHTML = this.matches.map((match, index) => ` +
+
+
${this.escapeHtml(match.name)}
+
${this.escapeHtml(match.url)}
+
+
+ `).join(''); + + // Scroll selected item into view + if (this.selectedIndex >= 0) { + const selectedItem = container.children[this.selectedIndex]; + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest' }); + } + } + } + + moveSelection(direction) { + const newIndex = this.selectedIndex + direction; + if (newIndex >= 0 && newIndex < this.matches.length) { + this.selectedIndex = newIndex; + this.renderResults(); + } + } + + selectCurrentItem() { + if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) { + this.selectItem(this.selectedIndex); + } + } + + selectItem(index) { + const match = this.matches[index]; + if (match) { + // Dispatch custom event + this.dispatchEvent(new CustomEvent('select', { + detail: match, + bubbles: true, + composed: true + })); + + // Navigate to URL + window.location.href = match.url; + + this.closeMenu(); + } + } + + openMenu() { + const dialog = this.shadowRoot.querySelector('dialog'); + const input = this.shadowRoot.querySelector('.search-input'); + + dialog.showModal(); + input.value = ''; + input.focus(); + + // Reset state - start with no items shown + this.matches = []; + this.selectedIndex = -1; + this.renderResults(); + } + + closeMenu() { + const dialog = this.shadowRoot.querySelector('dialog'); + dialog.close(); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +// Register the custom element +customElements.define('navigation-search', NavigationSearch); \ No newline at end of file diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 0b2def5a..0d89e11c 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -72,5 +72,7 @@ {% endfor %} {% if select_templates %}{% endif %} + + diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py new file mode 100644 index 00000000..4dda404b --- /dev/null +++ b/datasette/utils/actions_sql.py @@ -0,0 +1,275 @@ +""" +SQL query builder for hierarchical permission checking. + +This module implements a cascading permission system based on the pattern +from the sqlite-permissions-poc. It builds SQL queries that: + +1. Start with all resources of a given type (from resource_type.resources_sql()) +2. Gather permission rules from plugins (via permission_resources_sql hook) +3. Apply cascading logic: child → parent → global +4. Apply DENY-beats-ALLOW at each level + +The core pattern is: +- Resources are identified by (parent, child) tuples +- Rules are evaluated at three levels: + - child: exact match on (parent, child) + - parent: match on (parent, NULL) + - global: match on (NULL, NULL) +- At the same level, DENY (allow=0) beats ALLOW (allow=1) +- Across levels, child beats parent beats global +""" + +from typing import Optional +from datasette.plugins import pm +from datasette.utils import await_me_maybe +from datasette.utils.permissions import PluginSQL + + +async def build_allowed_resources_sql( + datasette, + actor: dict | None, + action: str, +) -> tuple[str, dict]: + """ + Build a SQL query that returns all resources the actor can access for this action. + + Args: + datasette: The Datasette instance + actor: The actor dict (or None for unauthenticated) + action: The action name (e.g., "view-table", "view-database") + + Returns: + A tuple of (sql_query, params_dict) + + The returned SQL query will have three columns: + - parent: The parent resource identifier (or NULL) + - child: The child resource identifier (or NULL) + - reason: The reason from the rule that granted access + + Example: + For action="view-table", this might return: + SELECT parent, child, reason FROM ... WHERE is_allowed = 1 + + Results would be like: + ('analytics', 'users', 'role-based: analysts can access analytics DB') + ('analytics', 'events', 'role-based: analysts can access analytics DB') + ('production', 'orders', 'business-exception: allow production.orders for carol') + """ + # Get the Action object + action_obj = datasette.actions.get(action) + if not action_obj: + raise ValueError(f"Unknown action: {action}") + + # Get base resources SQL from the resource class + base_resources_sql = action_obj.resource_class.resources_sql() + + # Get all permission rule fragments from plugins via the hook + rule_results = pm.hook.permission_resources_sql( + datasette=datasette, + actor=actor, + action=action, + ) + + # Combine rule fragments and collect parameters + all_params = {} + rule_sqls = [] + + for result in rule_results: + result = await await_me_maybe(result) + if result is None: + continue + if isinstance(result, list): + for plugin_sql in result: + if isinstance(plugin_sql, PluginSQL): + rule_sqls.append(plugin_sql.sql) + all_params.update(plugin_sql.params) + elif isinstance(result, PluginSQL): + rule_sqls.append(result.sql) + all_params.update(result.params) + + # If no rules, return empty result (deny all) + if not rule_sqls: + return "SELECT NULL AS parent, NULL AS child WHERE 0", {} + + # Build the cascading permission query + rules_union = " UNION ALL ".join(rule_sqls) + + query = f""" +WITH +base AS ( + {base_resources_sql} +), +all_rules AS ( + {rules_union} +), +child_lvl AS ( + SELECT b.parent, b.child, + MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, + MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow, + MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason, + MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason + FROM base b + LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child + GROUP BY b.parent, b.child +), +parent_lvl AS ( + SELECT b.parent, b.child, + MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, + MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow, + MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason, + MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason + FROM base b + LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL + GROUP BY b.parent, b.child +), +global_lvl AS ( + SELECT b.parent, b.child, + MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, + MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow, + MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason, + MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason + FROM base b + LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL + GROUP BY b.parent, b.child +), +decisions AS ( + SELECT + b.parent, b.child, + CASE + WHEN cl.any_deny = 1 THEN 0 + WHEN cl.any_allow = 1 THEN 1 + WHEN pl.any_deny = 1 THEN 0 + WHEN pl.any_allow = 1 THEN 1 + WHEN gl.any_deny = 1 THEN 0 + WHEN gl.any_allow = 1 THEN 1 + ELSE 0 + END AS is_allowed, + CASE + WHEN cl.any_deny = 1 THEN cl.deny_reason + WHEN cl.any_allow = 1 THEN cl.allow_reason + WHEN pl.any_deny = 1 THEN pl.deny_reason + WHEN pl.any_allow = 1 THEN pl.allow_reason + WHEN gl.any_deny = 1 THEN gl.deny_reason + WHEN gl.any_allow = 1 THEN gl.allow_reason + ELSE 'default deny' + END AS reason + FROM base b + JOIN child_lvl cl USING (parent, child) + JOIN parent_lvl pl USING (parent, child) + JOIN global_lvl gl USING (parent, child) +) +SELECT parent, child, reason +FROM decisions +WHERE is_allowed = 1 +ORDER BY parent, child +""" + return query.strip(), all_params + + +async def check_permission_for_resource( + datasette, + actor: dict | None, + action: str, + parent: Optional[str], + child: Optional[str], +) -> bool: + """ + Check if an actor has permission for a specific action on a specific resource. + + Args: + datasette: The Datasette instance + actor: The actor dict (or None) + action: The action name + parent: The parent resource identifier (e.g., database name, or None) + child: The child resource identifier (e.g., table name, or None) + + Returns: + True if the actor is allowed, False otherwise + + This builds the cascading permission query and checks if the specific + resource is in the allowed set. + """ + # Get the Action object + action_obj = datasette.actions.get(action) + if not action_obj: + raise ValueError(f"Unknown action: {action}") + + # Get all permission rule fragments from plugins via the hook + rule_results = pm.hook.permission_resources_sql( + datasette=datasette, + actor=actor, + action=action, + ) + + # Combine rule fragments and collect parameters + all_params = {} + rule_sqls = [] + + for result in rule_results: + result = await await_me_maybe(result) + if result is None: + continue + if isinstance(result, list): + for plugin_sql in result: + if isinstance(plugin_sql, PluginSQL): + rule_sqls.append(plugin_sql.sql) + all_params.update(plugin_sql.params) + elif isinstance(result, PluginSQL): + rule_sqls.append(result.sql) + all_params.update(result.params) + + # If no rules, default deny + if not rule_sqls: + return False + + # Build a simplified query that just checks for this one resource + rules_union = " UNION ALL ".join(rule_sqls) + + # Add parameters for the resource we're checking + all_params["_check_parent"] = parent + all_params["_check_child"] = child + + query = f""" +WITH +all_rules AS ( + {rules_union} +), +child_lvl AS ( + SELECT + MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, + MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow + FROM all_rules ar + WHERE ar.parent = :_check_parent AND ar.child = :_check_child +), +parent_lvl AS ( + SELECT + MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, + MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow + FROM all_rules ar + WHERE ar.parent = :_check_parent AND ar.child IS NULL +), +global_lvl AS ( + SELECT + MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, + MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow + FROM all_rules ar + WHERE ar.parent IS NULL AND ar.child IS NULL +) +SELECT + CASE + WHEN cl.any_deny = 1 THEN 0 + WHEN cl.any_allow = 1 THEN 1 + WHEN pl.any_deny = 1 THEN 0 + WHEN pl.any_allow = 1 THEN 1 + WHEN gl.any_deny = 1 THEN 0 + WHEN gl.any_allow = 1 THEN 1 + ELSE 0 + END AS is_allowed +FROM child_lvl cl, parent_lvl pl, global_lvl gl +""" + + # Execute the query against the internal database + result = await datasette.get_internal_database().execute(query, all_params) + if result.rows: + return bool(result.rows[0][0]) + return False diff --git a/datasette/views/special.py b/datasette/views/special.py index 7e5ce517..2c5004d0 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -923,3 +923,48 @@ class ApiExplorerView(BaseView): "private": private, }, ) + + +class TablesView(BaseView): + """ + Simple endpoint that uses the new allowed_resources() API. + Returns JSON list of all tables the actor can view. + + Supports ?q=foo+bar to filter tables matching .*foo.*bar.* pattern, + ordered by shortest name first. + """ + + name = "tables" + has_json_alternate = False + + async def get(self, request): + # Use the new allowed_resources() method + tables = await self.ds.allowed_resources("view-table", request.actor) + + # Convert to list of matches with name and url + matches = [ + { + "name": f"{table.parent}/{table.child}", + "url": self.ds.urls.table(table.parent, table.child), + } + for table in tables + ] + + # Apply search filter if q parameter is present + q = request.args.get("q", "").strip() + if q: + import re + + # Split search terms by whitespace + terms = q.split() + # Build regex pattern: .*term1.*term2.*term3.* + pattern = ".*" + ".*".join(re.escape(term) for term in terms) + ".*" + regex = re.compile(pattern, re.IGNORECASE) + + # Filter tables matching the pattern (extract table name from "db/table") + matches = [m for m in matches if regex.match(m["name"].split("/", 1)[1])] + + # Sort by shortest table name first + matches.sort(key=lambda m: len(m["name"].split("/", 1)[1])) + + return Response.json({"matches": matches}) diff --git a/docs/introspection.rst b/docs/introspection.rst index ff78ec78..19c6bffb 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -144,6 +144,47 @@ Shows currently attached databases. `Databases example `_: + +.. code-block:: json + + { + "matches": [ + { + "name": "fixtures/facetable", + "url": "/fixtures/facetable" + }, + { + "name": "fixtures/searchable", + "url": "/fixtures/searchable" + } + ] + } + +Search example with ``?q=facet`` returns only tables matching ``.*facet.*``: + +.. code-block:: json + + { + "matches": [ + { + "name": "fixtures/facetable", + "url": "/fixtures/facetable" + } + ] + } + +When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first. + .. _JsonDataView_threads: /-/threads diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 244f448d..66c78f7e 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -782,6 +782,9 @@ The plugin hook can then be used to register the new facet class like this: register_permissions(datasette) -------------------------------- +.. note:: + This hook is deprecated. Use :ref:`plugin_register_actions` instead, which provides a more flexible resource-based permission system. + If your plugin needs to register additional permissions unique to that plugin - ``upload-csvs`` for example - you can return a list of those permissions from this hook. .. code-block:: python @@ -824,6 +827,141 @@ The fields of the ``Permission`` class are as follows: This should only be ``True`` if you want anonymous users to be able to take this action. +.. _plugin_register_actions: + +register_actions(datasette) +---------------------------- + +If your plugin needs to register actions that can be checked with Datasette's new resource-based permission system, return a list of those actions from this hook. + +Actions define what operations can be performed on resources (like viewing a table, executing SQL, or custom plugin actions). + +.. code-block:: python + + from datasette import hookimpl + from datasette.permissions import Action, Resource + + + class DocumentCollectionResource(Resource): + """A collection of documents.""" + + name = "document-collection" + parent_name = None + + def __init__(self, collection: str): + super().__init__(parent=collection, child=None) + + @classmethod + def resources_sql(cls) -> str: + return """ + SELECT collection_name AS parent, NULL AS child + FROM document_collections + """ + + + class DocumentResource(Resource): + """A document in a collection.""" + + name = "document" + parent_name = "document-collection" + + def __init__(self, collection: str, document: str): + super().__init__(parent=collection, child=document) + + @classmethod + def resources_sql(cls) -> str: + return """ + SELECT collection_name AS parent, document_id AS child + FROM documents + """ + + + @hookimpl + def register_actions(datasette): + return [ + Action( + name="list-documents", + abbr="ld", + description="List documents in a collection", + takes_parent=True, + takes_child=False, + resource_class=DocumentCollectionResource, + ), + Action( + name="view-document", + abbr="vdoc", + description="View document", + takes_parent=True, + takes_child=True, + resource_class=DocumentResource, + ), + Action( + name="edit-document", + abbr="edoc", + description="Edit document", + takes_parent=True, + takes_child=True, + resource_class=DocumentResource, + ), + ] + +The fields of the ``Action`` dataclass are as follows: + +``name`` - string + The name of the action, e.g. ``view-document``. This should be unique across all plugins. + +``abbr`` - string or None + An abbreviation of the action, e.g. ``vdoc``. This is optional. Since this needs to be unique across all installed plugins it's best to choose carefully or use ``None``. + +``description`` - string or None + A human-readable description of what the action allows you to do. + +``takes_parent`` - boolean + ``True`` if this action requires a parent identifier (like a database name). + +``takes_child`` - boolean + ``True`` if this action requires a child identifier (like a table or document name). + +``resource_class`` - type[Resource] + The Resource subclass that defines what kind of resource this action applies to. Your Resource subclass must: + + - Define a ``name`` class attribute (e.g., ``"document"``) + - Optionally define a ``parent_name`` class attribute (e.g., ``"collection"``) + - Implement a ``resources_sql()`` classmethod that returns SQL returning all resources as ``(parent, child)`` columns + - Have an ``__init__`` method that accepts appropriate parameters and calls ``super().__init__(parent=..., child=...)`` + +The ``resources_sql()`` method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``resources_sql()`` classmethod is crucial to Datasette's permission system. It returns a SQL query that lists all resources of that type that exist in the system. + +This SQL query is used by Datasette to efficiently check permissions across multiple resources at once. When a user requests a list of resources (like tables, documents, or other entities), Datasette uses this SQL to: + +1. Get all resources of this type from your data catalog +2. Combine it with permission rules from the ``permission_resources_sql`` hook +3. Use SQL joins and filtering to determine which resources the actor can access +4. Return only the permitted resources + +The SQL query **must** return exactly two columns: + +- ``parent`` - The parent identifier (e.g., database name, collection name), or ``NULL`` for top-level resources +- ``child`` - The child identifier (e.g., table name, document ID), or ``NULL`` for parent-only resources + +For example, if you're building a document management plugin with collections and documents stored in a ``documents`` table, your ``resources_sql()`` might look like: + +.. code-block:: python + + @classmethod + def resources_sql(cls) -> str: + return """ + SELECT collection_name AS parent, document_id AS child + FROM documents + """ + +This tells Datasette "here's how to find all documents in the system - look in the documents table and get the collection name and document ID for each one." + +The permission system then uses this query along with rules from plugins to determine which documents each user can access, all efficiently in SQL rather than loading everything into Python. + .. _plugin_asgi_wrapper: asgi_wrapper(datasette) diff --git a/tests/test_actions_sql.py b/tests/test_actions_sql.py new file mode 100644 index 00000000..8fc8803d --- /dev/null +++ b/tests/test_actions_sql.py @@ -0,0 +1,317 @@ +""" +Tests for the new Resource-based permission system. + +These tests verify: +1. The new Datasette.allowed_resources() method +2. The new Datasette.allowed() method +3. The new Datasette.allowed_resources_with_reasons() method +4. That SQL does the heavy lifting (no Python filtering) +""" + +import pytest +import pytest_asyncio +from datasette.app import Datasette +from datasette.plugins import pm +from datasette.utils.permissions import PluginSQL +from datasette.default_actions import TableResource +from datasette import hookimpl + + +# Test plugin that provides permission rules +class PermissionRulesPlugin: + def __init__(self, rules_callback): + self.rules_callback = rules_callback + + @hookimpl + def permission_resources_sql(self, datasette, actor, action): + """Return permission rules based on the callback""" + return self.rules_callback(datasette, actor, action) + + +@pytest_asyncio.fixture +async def test_ds(): + """Create a test Datasette instance with sample data""" + ds = Datasette() + await ds.invoke_startup() + + # Add test databases with some tables + db = ds.add_memory_database("analytics") + await db.execute_write("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)") + await db.execute_write("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)") + await db.execute_write( + "CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)" + ) + + db2 = ds.add_memory_database("production") + await db2.execute_write( + "CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)" + ) + await db2.execute_write( + "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)" + ) + + # Refresh schemas to populate catalog_tables in internal database + await ds._refresh_schemas() + + return ds + + +@pytest.mark.asyncio +async def test_allowed_resources_global_allow(test_ds): + """Test allowed_resources() with a global allow rule""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("id") == "alice": + sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason" + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + # Use the new allowed_resources() method + tables = await test_ds.allowed_resources("view-table", {"id": "alice"}) + + # Alice should see all tables + assert len(tables) == 5 + assert all(isinstance(t, TableResource) for t in tables) + + # Check specific tables are present + table_set = set((t.parent, t.child) for t in tables) + assert ("analytics", "events") in table_set + assert ("analytics", "users") in table_set + assert ("analytics", "sensitive") in table_set + assert ("production", "customers") in table_set + assert ("production", "orders") in table_set + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_allowed_specific_resource(test_ds): + """Test allowed() method checks specific resource efficiently""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("role") == "analyst": + # Allow analytics database, deny everything else (global deny) + sql = """ + SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason + UNION ALL + SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + actor = {"id": "bob", "role": "analyst"} + + # Check specific resources using allowed() + # This should use SQL WHERE clause, not fetch all resources + assert await test_ds.allowed( + "view-table", TableResource("analytics", "users"), actor + ) + assert await test_ds.allowed( + "view-table", TableResource("analytics", "events"), actor + ) + assert not await test_ds.allowed( + "view-table", TableResource("production", "orders"), actor + ) + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_allowed_resources_with_reasons(test_ds): + """Test allowed_resources_with_reasons() exposes debugging info""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("role") == "analyst": + sql = """ + SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, + 'parent: analyst access to analytics' AS reason + UNION ALL + SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, + 'child: sensitive data denied' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + # Use allowed_resources_with_reasons to get debugging info + allowed = await test_ds.allowed_resources_with_reasons( + "view-table", {"id": "bob", "role": "analyst"} + ) + + # Should get analytics tables except sensitive + assert len(allowed) >= 2 # At least users and events + + # Check we can access both resource and reason + for item in allowed: + assert isinstance(item.resource, TableResource) + assert isinstance(item.reason, str) + if item.resource.parent == "analytics": + # Should mention parent-level reason + assert "analyst access" in item.reason.lower() + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_child_deny_overrides_parent_allow(test_ds): + """Test that child-level DENY beats parent-level ALLOW""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("role") == "analyst": + sql = """ + SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, + 'parent: allow analytics' AS reason + UNION ALL + SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, + 'child: deny sensitive' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + actor = {"id": "bob", "role": "analyst"} + tables = await test_ds.allowed_resources("view-table", actor) + + # Should see analytics tables except sensitive + analytics_tables = [t for t in tables if t.parent == "analytics"] + assert len(analytics_tables) >= 2 + + table_names = {t.child for t in analytics_tables} + assert "users" in table_names + assert "events" in table_names + assert "sensitive" not in table_names + + # Verify with allowed() method + assert await test_ds.allowed( + "view-table", TableResource("analytics", "users"), actor + ) + assert not await test_ds.allowed( + "view-table", TableResource("analytics", "sensitive"), actor + ) + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_child_allow_overrides_parent_deny(test_ds): + """Test that child-level ALLOW beats parent-level DENY""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("id") == "carol": + sql = """ + SELECT 'production' AS parent, NULL AS child, 0 AS allow, + 'parent: deny production' AS reason + UNION ALL + SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, + 'child: carol can see orders' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + actor = {"id": "carol"} + tables = await test_ds.allowed_resources("view-table", actor) + + # Should only see production.orders + production_tables = [t for t in tables if t.parent == "production"] + assert len(production_tables) == 1 + assert production_tables[0].child == "orders" + + # Verify with allowed() method + assert await test_ds.allowed( + "view-table", TableResource("production", "orders"), actor + ) + assert not await test_ds.allowed( + "view-table", TableResource("production", "customers"), actor + ) + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_resource_equality_and_hashing(test_ds): + """Test that Resource instances support equality and hashing""" + + # Create some resources + r1 = TableResource("analytics", "users") + r2 = TableResource("analytics", "users") + r3 = TableResource("analytics", "events") + + # Test equality + assert r1 == r2 + assert r1 != r3 + + # Test they can be used in sets + resource_set = {r1, r2, r3} + assert len(resource_set) == 2 # r1 and r2 are the same + + # Test they can be used as dict keys + resource_dict = {r1: "data1", r3: "data2"} + assert resource_dict[r2] == "data1" # r2 same as r1 + + +@pytest.mark.asyncio +async def test_sql_does_filtering_not_python(test_ds): + """ + Verify that allowed() uses SQL WHERE clause, not Python filtering. + + This test doesn't actually verify the SQL itself (that would require + query introspection), but it demonstrates the API contract. + """ + + def rules_callback(datasette, actor, action): + # Deny everything by default, allow only analytics.users specifically + sql = """ + SELECT NULL AS parent, NULL AS child, 0 AS allow, + 'global deny' AS reason + UNION ALL + SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, + 'specific allow' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + actor = {"id": "dave"} + + # allowed() should execute a targeted SQL query + # NOT fetch all resources and filter in Python + assert await test_ds.allowed( + "view-table", TableResource("analytics", "users"), actor + ) + assert not await test_ds.allowed( + "view-table", TableResource("analytics", "events"), actor + ) + + # allowed_resources() should also use SQL filtering + tables = await test_ds.allowed_resources("view-table", actor) + assert len(tables) == 1 + assert tables[0].parent == "analytics" + assert tables[0].child == "users" + + finally: + pm.unregister(plugin, name="test_plugin") diff --git a/tests/test_tables_endpoint.py b/tests/test_tables_endpoint.py new file mode 100644 index 00000000..a3305406 --- /dev/null +++ b/tests/test_tables_endpoint.py @@ -0,0 +1,544 @@ +""" +Tests for the /-/tables endpoint. + +These tests verify that the new TablesView correctly uses the allowed_resources() API. +""" + +import pytest +import pytest_asyncio +from datasette.app import Datasette +from datasette.plugins import pm +from datasette.utils.permissions import PluginSQL +from datasette import hookimpl + + +# Test plugin that provides permission rules +class PermissionRulesPlugin: + def __init__(self, rules_callback): + self.rules_callback = rules_callback + + @hookimpl + def permission_resources_sql(self, datasette, actor, action): + return self.rules_callback(datasette, actor, action) + + +@pytest_asyncio.fixture(scope="function") +async def test_ds(): + """Create a test Datasette instance with sample data (fresh for each test)""" + ds = Datasette() + await ds.invoke_startup() + + # Add test databases with some tables + db = ds.add_memory_database("analytics") + await db.execute_write("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)") + await db.execute_write("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)") + await db.execute_write( + "CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)" + ) + + db2 = ds.add_memory_database("production") + await db2.execute_write( + "CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)" + ) + await db2.execute_write( + "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)" + ) + + # Refresh schemas to populate catalog_tables in internal database + await ds._refresh_schemas() + + return ds + + +@pytest.mark.asyncio +async def test_tables_endpoint_global_access(test_ds): + """Test /-/tables with global access permissions""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("id") == "alice": + sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason" + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + # Use the allowed_resources API directly + tables = await test_ds.allowed_resources("view-table", {"id": "alice"}) + + # Convert to the format the endpoint returns + result = [ + { + "name": f"{t.parent}/{t.child}", + "url": test_ds.urls.table(t.parent, t.child), + } + for t in tables + ] + + # Alice should see all tables + assert len(result) == 5 + table_names = {m["name"] for m in result} + assert "analytics/events" in table_names + assert "analytics/users" in table_names + assert "analytics/sensitive" in table_names + assert "production/customers" in table_names + assert "production/orders" in table_names + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_tables_endpoint_database_restriction(test_ds): + """Test /-/tables with database-level restriction""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("role") == "analyst": + # Allow only analytics database + sql = "SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason" + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + tables = await test_ds.allowed_resources( + "view-table", {"id": "bob", "role": "analyst"} + ) + result = [ + { + "name": f"{t.parent}/{t.child}", + "url": test_ds.urls.table(t.parent, t.child), + } + for t in tables + ] + + # Bob should only see analytics tables + analytics_tables = [m for m in result if m["name"].startswith("analytics/")] + production_tables = [m for m in result if m["name"].startswith("production/")] + + assert len(analytics_tables) == 3 + table_names = {m["name"] for m in analytics_tables} + assert "analytics/events" in table_names + assert "analytics/users" in table_names + assert "analytics/sensitive" in table_names + + # Should not see production tables (unless default_permissions allows them) + # Note: default_permissions.py provides default allows, so we just check analytics are present + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_tables_endpoint_table_exception(test_ds): + """Test /-/tables with table-level exception (deny database, allow specific table)""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("id") == "carol": + # Deny analytics database, but allow analytics.users specifically + sql = """ + SELECT 'analytics' AS parent, NULL AS child, 0 AS allow, 'deny analytics' AS reason + UNION ALL + SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'carol exception' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + tables = await test_ds.allowed_resources("view-table", {"id": "carol"}) + result = [ + { + "name": f"{t.parent}/{t.child}", + "url": test_ds.urls.table(t.parent, t.child), + } + for t in tables + ] + + # Carol should see analytics.users but not other analytics tables + analytics_tables = [m for m in result if m["name"].startswith("analytics/")] + assert len(analytics_tables) == 1 + table_names = {m["name"] for m in analytics_tables} + assert "analytics/users" in table_names + + # Should NOT see analytics.events or analytics.sensitive + assert "analytics/events" not in table_names + assert "analytics/sensitive" not in table_names + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_tables_endpoint_deny_overrides_allow(test_ds): + """Test that child-level DENY beats parent-level ALLOW""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("role") == "analyst": + # Allow analytics, but deny sensitive table + sql = """ + SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'allow analytics' AS reason + UNION ALL + SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + tables = await test_ds.allowed_resources( + "view-table", {"id": "bob", "role": "analyst"} + ) + result = [ + { + "name": f"{t.parent}/{t.child}", + "url": test_ds.urls.table(t.parent, t.child), + } + for t in tables + ] + + analytics_tables = [m for m in result if m["name"].startswith("analytics/")] + + # Should see users and events but NOT sensitive + table_names = {m["name"] for m in analytics_tables} + assert "analytics/users" in table_names + assert "analytics/events" in table_names + assert "analytics/sensitive" not in table_names + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_tables_endpoint_no_permissions(): + """Test /-/tables when user has no custom permissions (only defaults)""" + + ds = Datasette() + await ds.invoke_startup() + + # Add a single database + db = ds.add_memory_database("testdb") + await db.execute_write("CREATE TABLE items (id INTEGER PRIMARY KEY)") + await ds._refresh_schemas() + + # Unknown actor with no custom permissions + tables = await ds.allowed_resources("view-table", {"id": "unknown"}) + result = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in tables + ] + + # Should see tables (due to default_permissions.py providing default allow) + assert len(result) >= 1 + assert any(m["name"].endswith("/items") for m in result) + + +@pytest.mark.asyncio +async def test_tables_endpoint_specific_table_only(test_ds): + """Test /-/tables when only specific tables are allowed (no parent/global rules)""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("id") == "dave": + # Allow only specific tables, no parent-level or global rules + sql = """ + SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'specific table 1' AS reason + UNION ALL + SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, 'specific table 2' AS reason + """ + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + tables = await test_ds.allowed_resources("view-table", {"id": "dave"}) + result = [ + { + "name": f"{t.parent}/{t.child}", + "url": test_ds.urls.table(t.parent, t.child), + } + for t in tables + ] + + # Should see only the two specifically allowed tables + specific_tables = [ + m for m in result if m["name"] in ("analytics/users", "production/orders") + ] + + assert len(specific_tables) == 2 + table_names = {m["name"] for m in specific_tables} + assert "analytics/users" in table_names + assert "production/orders" in table_names + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_tables_endpoint_empty_result(test_ds): + """Test /-/tables when all tables are explicitly denied""" + + def rules_callback(datasette, actor, action): + if actor and actor.get("id") == "blocked": + # Global deny + sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason" + return PluginSQL(source="test", sql=sql, params={}) + return None + + plugin = PermissionRulesPlugin(rules_callback) + pm.register(plugin, name="test_plugin") + + try: + tables = await test_ds.allowed_resources("view-table", {"id": "blocked"}) + result = [ + { + "name": f"{t.parent}/{t.child}", + "url": test_ds.urls.table(t.parent, t.child), + } + for t in tables + ] + + # Global deny should block access to all tables + assert len(result) == 0 + + finally: + pm.unregister(plugin, name="test_plugin") + + +@pytest.mark.asyncio +async def test_tables_endpoint_search_single_term(): + """Test /-/tables?q=user to filter tables matching 'user'""" + + ds = Datasette() + await ds.invoke_startup() + + # Add database with various table names + db = ds.add_memory_database("search_test") + await db.execute_write("CREATE TABLE users (id INTEGER)") + await db.execute_write("CREATE TABLE user_profiles (id INTEGER)") + await db.execute_write("CREATE TABLE events (id INTEGER)") + await db.execute_write("CREATE TABLE posts (id INTEGER)") + await ds._refresh_schemas() + + # Get all tables in the new format + all_tables = await ds.allowed_resources("view-table", None) + matches = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in all_tables + ] + + # Filter for "user" (extract table name from "db/table") + import re + + pattern = ".*user.*" + regex = re.compile(pattern, re.IGNORECASE) + filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])] + + # Should match users and user_profiles but not events or posts + table_names = {m["name"].split("/", 1)[1] for m in filtered} + assert "users" in table_names + assert "user_profiles" in table_names + assert "events" not in table_names + assert "posts" not in table_names + + +@pytest.mark.asyncio +async def test_tables_endpoint_search_multiple_terms(): + """Test /-/tables?q=user+profile to filter tables matching .*user.*profile.*""" + + ds = Datasette() + await ds.invoke_startup() + + # Add database with various table names + db = ds.add_memory_database("search_test2") + await db.execute_write("CREATE TABLE user_profiles (id INTEGER)") + await db.execute_write("CREATE TABLE users (id INTEGER)") + await db.execute_write("CREATE TABLE profile_settings (id INTEGER)") + await db.execute_write("CREATE TABLE events (id INTEGER)") + await ds._refresh_schemas() + + # Get all tables in the new format + all_tables = await ds.allowed_resources("view-table", None) + matches = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in all_tables + ] + + # Filter for "user profile" (two terms, extract table name from "db/table") + import re + + terms = ["user", "profile"] + pattern = ".*" + ".*".join(re.escape(term) for term in terms) + ".*" + regex = re.compile(pattern, re.IGNORECASE) + filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])] + + # Should match only user_profiles (has both user and profile in that order) + table_names = {m["name"].split("/", 1)[1] for m in filtered} + assert "user_profiles" in table_names + assert "users" not in table_names # doesn't have "profile" + assert "profile_settings" not in table_names # doesn't have "user" + + +@pytest.mark.asyncio +async def test_tables_endpoint_search_ordering(): + """Test that search results are ordered by shortest name first""" + + ds = Datasette() + await ds.invoke_startup() + + # Add database with tables of various lengths containing "user" + db = ds.add_memory_database("order_test") + await db.execute_write("CREATE TABLE users (id INTEGER)") + await db.execute_write("CREATE TABLE user_profiles (id INTEGER)") + await db.execute_write( + "CREATE TABLE u (id INTEGER)" + ) # Shortest, but doesn't match "user" + await db.execute_write( + "CREATE TABLE user_authentication_tokens (id INTEGER)" + ) # Longest + await db.execute_write("CREATE TABLE user_data (id INTEGER)") + await ds._refresh_schemas() + + # Get all tables in the new format + all_tables = await ds.allowed_resources("view-table", None) + matches = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in all_tables + ] + + # Filter for "user" and sort by table name length + import re + + pattern = ".*user.*" + regex = re.compile(pattern, re.IGNORECASE) + filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])] + filtered.sort(key=lambda m: len(m["name"].split("/", 1)[1])) + + # Should be ordered: users, user_data, user_profiles, user_authentication_tokens + matching_names = [m["name"].split("/", 1)[1] for m in filtered] + assert matching_names[0] == "users" # shortest + assert len(matching_names[0]) < len(matching_names[1]) + assert len(matching_names[-1]) > len(matching_names[-2]) + assert matching_names[-1] == "user_authentication_tokens" # longest + + +@pytest.mark.asyncio +async def test_tables_endpoint_search_case_insensitive(): + """Test that search is case-insensitive""" + + ds = Datasette() + await ds.invoke_startup() + + # Add database with mixed case table names + db = ds.add_memory_database("case_test") + await db.execute_write("CREATE TABLE Users (id INTEGER)") + await db.execute_write("CREATE TABLE USER_PROFILES (id INTEGER)") + await db.execute_write("CREATE TABLE user_data (id INTEGER)") + await ds._refresh_schemas() + + # Get all tables in the new format + all_tables = await ds.allowed_resources("view-table", None) + matches = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in all_tables + ] + + # Filter for "user" (lowercase) should match all case variants + import re + + pattern = ".*user.*" + regex = re.compile(pattern, re.IGNORECASE) + filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])] + + # Should match all three tables regardless of case + table_names = {m["name"].split("/", 1)[1] for m in filtered} + assert "Users" in table_names + assert "USER_PROFILES" in table_names + assert "user_data" in table_names + assert len(filtered) >= 3 + + +@pytest.mark.asyncio +async def test_tables_endpoint_search_no_matches(): + """Test search with no matching tables returns empty list""" + + ds = Datasette() + await ds.invoke_startup() + + # Add database with tables that won't match search + db = ds.add_memory_database("nomatch_test") + await db.execute_write("CREATE TABLE events (id INTEGER)") + await db.execute_write("CREATE TABLE posts (id INTEGER)") + await ds._refresh_schemas() + + # Get all tables in the new format + all_tables = await ds.allowed_resources("view-table", None) + matches = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in all_tables + ] + + # Filter for "zzz" which doesn't exist + import re + + pattern = ".*zzz.*" + regex = re.compile(pattern, re.IGNORECASE) + filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])] + + # Should return empty list + assert len(filtered) == 0 + + +@pytest.mark.asyncio +async def test_tables_endpoint_config_database_allow(): + """Test that database-level allow blocks work for view-table action""" + + # Simulate: -s databases.fixtures.allow.id root + config = {"databases": {"fixtures": {"allow": {"id": "root"}}}} + + ds = Datasette(config=config) + await ds.invoke_startup() + + # Create databases + fixtures_db = ds.add_memory_database("fixtures") + await fixtures_db.execute_write("CREATE TABLE users (id INTEGER)") + await fixtures_db.execute_write("CREATE TABLE posts (id INTEGER)") + + content_db = ds.add_memory_database("content") + await content_db.execute_write("CREATE TABLE articles (id INTEGER)") + + await ds._refresh_schemas() + + # Root user should see fixtures tables + root_tables = await ds.allowed_resources("view-table", {"id": "root"}) + root_list = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in root_tables + ] + fixtures_tables_root = [m for m in root_list if m["name"].startswith("fixtures/")] + assert len(fixtures_tables_root) == 2 + table_names = {m["name"] for m in fixtures_tables_root} + assert "fixtures/users" in table_names + assert "fixtures/posts" in table_names + + # Alice should NOT see fixtures tables + alice_tables = await ds.allowed_resources("view-table", {"id": "alice"}) + alice_list = [ + {"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)} + for t in alice_tables + ] + fixtures_tables_alice = [m for m in alice_list if m["name"].startswith("fixtures/")] + assert len(fixtures_tables_alice) == 0 + + # But Alice should see content tables (no restrictions) + content_tables_alice = [m for m in alice_list if m["name"].startswith("content/")] + assert len(content_tables_alice) == 1 + assert "content/articles" in {m["name"] for m in content_tables_alice} From 5b0baf7cd5ea99c6366052649f31e0a3a608d014 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 20 Oct 2025 16:03:22 -0700 Subject: [PATCH 011/187] Ran prettier --- datasette/static/navigation-search.js | 415 +++++++++++++------------- 1 file changed, 213 insertions(+), 202 deletions(-) diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 202839d5..7204ab93 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -1,17 +1,17 @@ class NavigationSearch extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - this.selectedIndex = -1; - this.matches = []; - this.debounceTimer = null; - - this.render(); - this.setupEventListeners(); - } + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.selectedIndex = -1; + this.matches = []; + this.debounceTimer = null; - render() { - this.shadowRoot.innerHTML = ` + this.render(); + this.setupEventListeners(); + } + + render() { + this.shadowRoot.innerHTML = ` + + +{% endif %} diff --git a/datasette/templates/actions.html b/datasette/templates/debug_actions.html similarity index 91% rename from datasette/templates/actions.html rename to datasette/templates/debug_actions.html index b4285d79..6dd5ac0e 100644 --- a/datasette/templates/actions.html +++ b/datasette/templates/debug_actions.html @@ -3,7 +3,10 @@ {% block title %}Registered Actions{% endblock %} {% block content %} -

Registered Actions

+

Registered actions

+ +{% set current_tab = "actions" %} +{% include "_permissions_debug_tabs.html" %}

This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}. diff --git a/datasette/templates/debug_allowed.html b/datasette/templates/debug_allowed.html index c3688e26..e3dc5250 100644 --- a/datasette/templates/debug_allowed.html +++ b/datasette/templates/debug_allowed.html @@ -9,8 +9,10 @@ {% endblock %} {% block content %} +

Allowed resources

-

Allowed Resources

+{% set current_tab = "allowed" %} +{% include "_permissions_debug_tabs.html" %}

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.

@@ -225,9 +227,6 @@ function displayResults(data) { // Update raw JSON document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data); - - // Scroll to results - resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function displayError(data) { @@ -238,8 +237,6 @@ function displayError(data) { resultsContent.innerHTML = `
Error: ${escapeHtml(data.error || 'Unknown error')}
`; document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data); - - resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // Disable child input if parent is empty diff --git a/datasette/templates/debug_check.html b/datasette/templates/debug_check.html index 47fce5cb..da990985 100644 --- a/datasette/templates/debug_check.html +++ b/datasette/templates/debug_check.html @@ -4,35 +4,9 @@ {% block extra_head %} +{% include "_permission_ui_styles.html" %} {% include "_debug_common_functions.html" %} {% endblock %} {% block content %} +

Permission check

-

Permission Check

+{% set current_tab = "check" %} +{% include "_permissions_debug_tabs.html" %}

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

@@ -105,32 +65,36 @@

Current actor: anonymous (not logged in)

{% endif %} -
-
- - - The permission action to check -
+
+ +
+ + + The permission action to check +
-
- - - For database-level permissions, specify the database name -
+
+ + + For database-level permissions, specify the database name +
-
- - - For table-level permissions, specify the table name (requires parent) -
+
+ + + For table-level permissions, specify the table name (requires parent) +
- - +
+ +
+ +