diff --git a/datasette/default_permissions/sqlite_statistics.py b/datasette/default_permissions/sqlite_statistics.py new file mode 100644 index 00000000..11fd4008 --- /dev/null +++ b/datasette/default_permissions/sqlite_statistics.py @@ -0,0 +1,25 @@ +"""Default table-access policy for SQLite optimizer statistics.""" + +import json + +from datasette import hookimpl +from datasette.permissions import PermissionSQL + + +@hookimpl +def permission_resources_sql(action): + if action != "view-table": + return None + return PermissionSQL( + sql=""" + SELECT database_name AS parent, value AS child, 0 AS allow, + 'SQLite statistics tables are denied by default' AS reason + FROM catalog_databases + CROSS JOIN json_each(:sqlite_statistics_names) + """, + params={ + "sqlite_statistics_names": json.dumps( + ["sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"] + ) + }, + ) diff --git a/datasette/plugins.py b/datasette/plugins.py index 9cf94079..6a4d7da7 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -18,6 +18,7 @@ DEFAULT_PLUGINS = ( "datasette.actor_auth_cookie", "datasette.default_permissions", "datasette.default_permissions.tokens", + "datasette.default_permissions.sqlite_statistics", "datasette.default_actions", "datasette.default_column_types", "datasette.default_magic_parameters", diff --git a/docs/authentication.rst b/docs/authentication.rst index c82cfa1d..b559d7ac 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -191,6 +191,18 @@ names and other resource types remain case-sensitive. The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules. +The built-in ``datasette.default_permissions.sqlite_statistics`` plugin denies +``view-table`` for ``sqlite_stat1``, ``sqlite_stat2``, ``sqlite_stat3`` and +``sqlite_stat4``. These table-level denials also apply to root users and take +precedence over configuration or plugin allow rules at the same scope. +This controls table access and listings, without changing ``execute-sql`` or +SQLite's internal use of statistics. + +A plugin can replace this policy by unregistering +``datasette.default_permissions.sqlite_statistics`` through ``datasette.pm`` +and registering its own permission hook. Plugin registration is process-wide: +replacing this policy affects every Datasette instance in that process. + Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. ``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified. diff --git a/tests/test_api.py b/tests/test_api.py index 76c30e46..2ab09f64 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -894,10 +894,7 @@ async def test_hidden_sqlite_stat1_table(): await db.execute_write("analyze") data = (await ds.client.get("/db.json?_show_hidden=1")).json() tables = [(t["name"], t["hidden"]) for t in data["tables"]] - assert tables in ( - [("normal", False), ("sqlite_stat1", True)], - [("normal", False), ("sqlite_stat1", True), ("sqlite_stat4", True)], - ) + assert tables == [("normal", False)] @pytest.mark.asyncio diff --git a/tests/test_pr76_statistics_policy.py b/tests/test_pr76_statistics_policy.py new file mode 100644 index 00000000..6ebf10b9 --- /dev/null +++ b/tests/test_pr76_statistics_policy.py @@ -0,0 +1,113 @@ +"""Statistics access policy and plugin replacement coverage for PR #76.""" + +import uuid + +import pytest + +from datasette import hookimpl +from datasette.app import Datasette +from datasette.permissions import PermissionSQL +from datasette.resources import TableResource + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", [None, "global", "database", "table", "root"]) +async def test_statistics_denied_despite_allow_rules(scope): + config = {"databases": {"data": {"tables": {"sqlite_stat1": {}}}}} + grant = {"view-table": True} + if scope == "global": + config["permissions"] = grant + elif scope == "database": + config["databases"]["data"]["permissions"] = grant + elif scope == "table": + config["databases"]["data"]["tables"]["sqlite_stat1"]["permissions"] = grant + ds = Datasette(memory=True, config=config) + ds.root_enabled = scope == "root" + actor = {"id": "root"} if scope == "root" else {"id": "reader"} + db = ds.add_memory_database(uuid.uuid4().hex, name="data") + await db.execute_write("create table items(value text)") + await db.execute_write("create index items_value on items(value)") + await db.execute_write("insert into items values ('example')") + await db.execute_write("analyze") + await ds.invoke_startup() + try: + assert "view-sqlite-statistics" not in ds.actions + for name in ("sqlite_stat1", "SQLITE_STAT1"): + assert not await ds.allowed( + action="view-table", resource=TableResource("data", name), actor=actor + ) + for suffix in ("", ".json", ".csv"): + assert ( + await ds.client.get(f"/data/sqlite_stat1{suffix}", actor=actor) + ).status_code == 403 + resources = await ds.allowed_resources("view-table", parent="data", actor=actor) + assert "sqlite_stat1" not in {r.child for r in resources.resources} + assert "items" in {r.child for r in resources.resources} + finally: + ds.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "table", ["sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"] +) +@pytest.mark.parametrize("default_deny", [False, True]) +async def test_statistics_names_denied(table, default_deny): + ds = Datasette(memory=True, default_deny=default_deny) + ds.root_enabled = True + await ds.invoke_startup() + try: + for name in (table, table.upper()): + assert not await ds.allowed( + action="view-table", + resource=TableResource("_memory", name), + actor={"id": "root"}, + ) + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_plugin_can_replace_statistics_policy(): + class ReplacementPolicy: + @hookimpl + def permission_resources_sql(self, action, actor): + if action == "view-table": + return PermissionSQL( + sql="SELECT 'data' AS parent, 'sqlite_stat1' AS child, :statistics_allowed AS allow, 'custom statistics policy' AS reason", + params={"statistics_allowed": int(actor == {"id": "reader"})}, + ) + + ds = Datasette(memory=True) + db = ds.add_memory_database(uuid.uuid4().hex, name="data") + await db.execute_write("create table items(value text)") + await db.execute_write("analyze") + await ds.invoke_startup() + name = "datasette.default_permissions.sqlite_statistics" + original = ds.pm.unregister(name=name) + assert original is not None + replacement = ReplacementPolicy() + ds.pm.register(replacement, name="test-replacement-statistics-policy") + try: + actor = {"id": "reader"} + assert await ds.allowed( + action="view-table", + resource=TableResource("data", "sqlite_stat1"), + actor=actor, + ) + assert not await ds.allowed( + action="view-table", resource=TableResource("data", "sqlite_stat1") + ) + resources = await ds.allowed_resources( + "view-table", parent="data", actor=actor, include_is_private=True + ) + stats = [r for r in resources.resources if r.child == "sqlite_stat1"] + assert len(stats) == 1 and stats[0].private + assert ( + await ds.client.get("/data/sqlite_stat1.json", actor=actor) + ).status_code == 200 + assert (await ds.client.get("/data/sqlite_stat1.json")).status_code == 403 + finally: + ds.pm.unregister(replacement) + ds.pm.register(original, name=name) + ds.close()