From 9397d718345c4b35d2a5c55bfcbd1468876b5ab9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 7 Jun 2020 21:47:22 -0700 Subject: [PATCH] Implemented view-table, refs #811 --- datasette/default_permissions.py | 8 ++ datasette/templates/database.html | 2 +- datasette/views/database.py | 16 ++++ tests/test_permissions.py | 123 ++++++++++++++++++++---------- 4 files changed, 108 insertions(+), 41 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 40be8d34..dd1770a3 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -17,6 +17,14 @@ def permission_allowed(datasette, actor, action, resource_type, resource_identif if database_allow is None: return True return actor_matches_allow(actor, database_allow) + elif action == "view-table": + assert resource_type == "table" + database, table = resource_identifier + tables = datasette.metadata("tables", database=database) or {} + table_allow = (tables.get(table) or {}).get("allow") + if table_allow is None: + return True + return actor_matches_allow(actor, table_allow) elif action == "view-query": # Check if this query has a "allow" block in metadata assert resource_type == "query" diff --git a/datasette/templates/database.html b/datasette/templates/database.html index dfafc049..1187267d 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -36,7 +36,7 @@ {% for table in tables %} {% if show_hidden or not table.hidden %}
-

{{ table.name }}{% if table.hidden %} (hidden){% endif %}

+

{{ table.name }}{% if table.private %} 🔒{% endif %}{% if table.hidden %} (hidden){% endif %}

{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}

{% if table.count is none %}Many rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}

diff --git a/datasette/views/database.py b/datasette/views/database.py index 4804b2a9..ba3d22d9 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -42,6 +42,21 @@ class DatabaseView(DataView): tables = [] for table in table_counts: + allowed = await self.ds.permission_allowed( + request.scope.get("actor"), + "view-table", + resource_type="table", + resource_identifier=(database, table), + default=True, + ) + if not allowed: + continue + private = not await self.ds.permission_allowed( + None, + "view-table", + resource_type="table", + resource_identifier=(database, table), + ) table_columns = await db.table_columns(table) tables.append( { @@ -52,6 +67,7 @@ class DatabaseView(DataView): "hidden": table in hidden_table_names, "fts_table": await db.fts_table(table), "foreign_keys": all_foreign_keys[table], + "private": private, } ) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index e66b9291..7c5b02c0 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -2,46 +2,6 @@ from .fixtures import make_app_client import pytest -@pytest.mark.parametrize( - "allow,expected_anon,expected_auth", - [(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),], -) -def test_view_query(allow, expected_anon, expected_auth): - with make_app_client( - metadata={ - "databases": { - "fixtures": {"queries": {"q": {"sql": "select 1 + 1", "allow": allow}}} - } - } - ) as client: - anon_response = client.get("/fixtures/q") - assert expected_anon == anon_response.status - auth_response = client.get( - "/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} - ) - assert expected_auth == auth_response.status - - -def test_query_list_respects_view_query(): - with make_app_client( - metadata={ - "databases": { - "fixtures": { - "queries": {"q": {"sql": "select 1 + 1", "allow": {"id": "root"}}} - } - } - } - ) as client: - html_fragment = '
  • q 🔒
  • ' - anon_response = client.get("/fixtures") - assert html_fragment not in anon_response.text - assert '"/fixtures/q"' not in anon_response.text - auth_response = client.get( - "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} - ) - assert html_fragment in auth_response.text - - @pytest.mark.parametrize( "allow,expected_anon,expected_auth", [(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),], @@ -96,3 +56,86 @@ def test_database_list_respects_view_database(): ) assert 'data' in auth_response.text assert 'fixtures 🔒' in auth_response.text + + +@pytest.mark.parametrize( + "allow,expected_anon,expected_auth", + [(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),], +) +def test_view_table(allow, expected_anon, expected_auth): + with make_app_client( + metadata={ + "databases": { + "fixtures": { + "tables": {"compound_three_primary_keys": {"allow": allow}} + } + } + } + ) as client: + anon_response = client.get("/fixtures/compound_three_primary_keys") + assert expected_anon == anon_response.status + auth_response = client.get( + "/fixtures/compound_three_primary_keys", + cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}, + ) + assert expected_auth == auth_response.status + + +def test_table_list_respects_view_table(): + with make_app_client( + metadata={ + "databases": { + "fixtures": { + "tables": {"compound_three_primary_keys": {"allow": {"id": "root"}}} + } + } + } + ) as client: + html_fragment = 'compound_three_primary_keys 🔒' + anon_response = client.get("/fixtures") + assert html_fragment not in anon_response.text + assert '"/fixtures/compound_three_primary_keys"' not in anon_response.text + auth_response = client.get( + "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} + ) + assert html_fragment in auth_response.text + + +@pytest.mark.parametrize( + "allow,expected_anon,expected_auth", + [(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),], +) +def test_view_query(allow, expected_anon, expected_auth): + with make_app_client( + metadata={ + "databases": { + "fixtures": {"queries": {"q": {"sql": "select 1 + 1", "allow": allow}}} + } + } + ) as client: + anon_response = client.get("/fixtures/q") + assert expected_anon == anon_response.status + auth_response = client.get( + "/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} + ) + assert expected_auth == auth_response.status + + +def test_query_list_respects_view_query(): + with make_app_client( + metadata={ + "databases": { + "fixtures": { + "queries": {"q": {"sql": "select 1 + 1", "allow": {"id": "root"}}} + } + } + } + ) as client: + html_fragment = '
  • q 🔒
  • ' + anon_response = client.get("/fixtures") + assert html_fragment not in anon_response.text + assert '"/fixtures/q"' not in anon_response.text + auth_response = client.get( + "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} + ) + assert html_fragment in auth_response.text