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