From 40885ef24e32d91502b6b8bbad1c7376f50f2830 Mon Sep 17 00:00:00 2001
From: Simon Willison
+ home
+ Actor: {{ check.actor|tojson }} Resource: {{ check.resource_type }}: {{ check.resource_identifier }} Set a message:Recent permissions checks
+
+{% for check in permission_checks %}
+
+ {{ check.action }}
+ checked at
+ {{ check.when }}
+ {% if check.result %}
+ ✓
+ {% else %}
+ ✗
+ {% endif %}
+ {% if check.used_default %}
+ (used default)
+ {% endif %}
+
+ Debug messages
+
+
Actor: {{ check.actor|tojson }}
{% if check.resource_type %} -Resource: {{ check.resource_type }}: {{ check.resource_identifier }}
+Resource: {{ check.resource_type }} = {{ check.resource_identifier }}
{% endif %}{")
@@ -1211,7 +1211,7 @@ def test_config_template_debug_off(app_client):
def test_debug_context_includes_extra_template_vars():
# https://github.com/simonw/datasette/issues/693
- for client in make_app_client(config={"template_debug": True}):
+ with make_app_client(config={"template_debug": True}) as client:
response = client.get("/fixtures/facetable?_context=1")
# scope_path is added by PLUGIN1
assert "scope_path" in response.text
@@ -1292,7 +1292,7 @@ def test_metadata_sort_desc(app_client):
],
)
def test_base_url_config(base_url, path):
- for client in make_app_client(config={"base_url": base_url}):
+ with make_app_client(config={"base_url": base_url}) as client:
response = client.get(base_url + path.lstrip("/"))
soup = Soup(response.body, "html.parser")
for el in soup.findAll(["a", "link", "script"]):
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index f69e7fa7..c782b87b 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -229,9 +229,9 @@ def test_plugins_asgi_wrapper(app_client):
def test_plugins_extra_template_vars(restore_working_directory):
- for client in make_app_client(
+ with make_app_client(
template_dir=str(pathlib.Path(__file__).parent / "test_templates")
- ):
+ ) as client:
response = client.get("/-/metadata")
assert response.status == 200
extra_template_vars = json.loads(
@@ -254,9 +254,9 @@ def test_plugins_extra_template_vars(restore_working_directory):
def test_plugins_async_template_function(restore_working_directory):
- for client in make_app_client(
+ with make_app_client(
template_dir=str(pathlib.Path(__file__).parent / "test_templates")
- ):
+ ) as client:
response = client.get("/-/metadata")
assert response.status == 200
extra_from_awaitable_function = (
From ece0ba6f4bc152af6f605fc5f536ffa46af95274 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 14:23:16 -0700
Subject: [PATCH 0063/1871] Test + default impl for view-query permission, refs
#811
---
datasette/default_permissions.py | 21 ++++++++++++++++++---
tests/test_permissions.py | 22 ++++++++++++++++++++++
2 files changed, 40 insertions(+), 3 deletions(-)
create mode 100644 tests/test_permissions.py
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index 0b0d17f9..40ae54ab 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -1,7 +1,22 @@
from datasette import hookimpl
+from datasette.utils import actor_matches_allow
@hookimpl
-def permission_allowed(actor, action, resource_type, resource_identifier):
- if actor and actor.get("id") == "root" and action == "permissions-debug":
- return True
+def permission_allowed(datasette, actor, action, resource_type, resource_identifier):
+ if action == "permissions-debug":
+ if actor and actor.get("id") == "root":
+ return True
+ elif action == "view-query":
+ # Check if this query has a "allow" block in metadata
+ assert resource_type == "query"
+ database, query_name = resource_identifier
+ queries_metadata = datasette.metadata("queries", database=database)
+ assert query_name in queries_metadata
+ if isinstance(queries_metadata[query_name], str):
+ return True
+ allow = queries_metadata[query_name].get("allow")
+ print("checking allow - actor = {}, allow = {}".format(actor, allow))
+ if allow is None:
+ return True
+ return actor_matches_allow(actor, allow)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
new file mode 100644
index 00000000..c90fdf7a
--- /dev/null
+++ b/tests/test_permissions.py
@@ -0,0 +1,22 @@
+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_execute_sql(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
From 8571ce388a23dd98adbdc1b7eff6c6eef5a9d1af Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 14:30:39 -0700
Subject: [PATCH 0064/1871] Implemented view-instance permission, refs #811
---
datasette/default_permissions.py | 4 ++++
tests/test_permissions.py | 20 ++++++++++++++++++++
2 files changed, 24 insertions(+)
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index 40ae54ab..ee182c85 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -7,6 +7,10 @@ def permission_allowed(datasette, actor, action, resource_type, resource_identif
if action == "permissions-debug":
if actor and actor.get("id") == "root":
return True
+ elif action == "view-instance":
+ allow = datasette.metadata("allow")
+ if allow is not None:
+ return actor_matches_allow(actor, allow)
elif action == "view-query":
# Check if this query has a "allow" block in metadata
assert resource_type == "query"
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index c90fdf7a..b5c2e00c 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -20,3 +20,23 @@ def test_execute_sql(allow, expected_anon, expected_auth):
"/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
)
assert expected_auth == auth_response.status
+
+
+@pytest.mark.parametrize(
+ "allow,expected_anon,expected_auth",
+ [(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),],
+)
+def test_view_instance(allow, expected_anon, expected_auth):
+ with make_app_client(metadata={"allow": allow}) as client:
+ for path in (
+ "/",
+ "/fixtures",
+ "/fixtures/compound_three_primary_keys",
+ "/fixtures/compound_three_primary_keys/a,a,a",
+ ):
+ anon_response = client.get(path)
+ assert expected_anon == anon_response.status
+ auth_response = client.get(
+ path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ )
+ assert expected_auth == auth_response.status
From cd92e4fe2a47039a8c780e4e7183a0d2e7446884 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 14:33:52 -0700
Subject: [PATCH 0065/1871] Fixed test name, this executes view-query, not
execute-sql - refs #811
---
tests/test_permissions.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index b5c2e00c..bf66bc9c 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -6,7 +6,7 @@ import pytest
"allow,expected_anon,expected_auth",
[(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),],
)
-def test_execute_sql(allow, expected_anon, expected_auth):
+def test_view_query(allow, expected_anon, expected_auth):
with make_app_client(
metadata={
"databases": {
From 613fa551a1be31645deb0ece4b46638c181827e0 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 20:14:27 -0700
Subject: [PATCH 0066/1871] Removed view-row permission, for the moment - refs
#811
https://github.com/simonw/datasette/issues/811#issuecomment-640338347
---
datasette/views/table.py | 3 ---
docs/authentication.rst | 13 -------------
tests/test_html.py | 1 -
3 files changed, 17 deletions(-)
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 10d6725a..935fed3d 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -851,9 +851,6 @@ class RowView(RowTableShared):
await self.check_permission(request, "view-instance")
await self.check_permission(request, "view-database", "database", database)
await self.check_permission(request, "view-table", "table", (database, table))
- await self.check_permission(
- request, "view-row", "row", tuple([database, table] + list(pk_values))
- )
db = self.ds.databases[database]
pks = await db.primary_keys(table)
use_rowid = not pks
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 1bf2a1a5..2caca66f 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -206,19 +206,6 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
``resource_identifier`` - tuple: (string, string)
The name of the database, then the name of the table
-.. _permissions_view_row:
-
-view-row
---------
-
-Actor is allowed to view a row page, e.g. https://latest.datasette.io/fixtures/compound_primary_key/a,b
-
-``resource_type`` - string
- "row"
-
-``resource_identifier`` - tuple: (string, string, strings...)
- The name of the database, then the name of the table, then the primary key of the row. The primary key may be a single value or multiple values, so the ``resource_identifier`` tuple may be three or more items long.
-
.. _permissions_view_query:
view-query
diff --git a/tests/test_html.py b/tests/test_html.py
index 4e913bcf..e05640d7 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -210,7 +210,6 @@ def test_row_page_does_not_truncate():
[
"view-instance",
("view-table", "table", ("fixtures", "facetable")),
- ("view-row", "row", ("fixtures", "facetable", "1")),
],
)
table = Soup(response.body, "html.parser").find("table")
From 9b42e1a4f5902fb7d6ad0111189900e2656ffda3 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 20:50:37 -0700
Subject: [PATCH 0067/1871] view-database permission
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also now using 🔒 to indicate private resources - resources that
would not be available to the anonymous user. Refs #811
---
datasette/default_permissions.py | 7 +++++-
datasette/templates/database.html | 2 +-
datasette/templates/index.html | 2 +-
datasette/views/database.py | 3 +--
datasette/views/index.py | 19 +++++++++++++++-
tests/test_canned_write.py | 11 +++++-----
tests/test_html.py | 5 +----
tests/test_permissions.py | 36 +++++++++++++++++++++++++++++++
8 files changed, 69 insertions(+), 16 deletions(-)
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index ee182c85..40be8d34 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -11,6 +11,12 @@ def permission_allowed(datasette, actor, action, resource_type, resource_identif
allow = datasette.metadata("allow")
if allow is not None:
return actor_matches_allow(actor, allow)
+ elif action == "view-database":
+ assert resource_type == "database"
+ database_allow = datasette.metadata("allow", database=resource_identifier)
+ if database_allow is None:
+ return True
+ return actor_matches_allow(actor, database_allow)
elif action == "view-query":
# Check if this query has a "allow" block in metadata
assert resource_type == "query"
@@ -20,7 +26,6 @@ def permission_allowed(datasette, actor, action, resource_type, resource_identif
if isinstance(queries_metadata[query_name], str):
return True
allow = queries_metadata[query_name].get("allow")
- print("checking allow - actor = {}, allow = {}".format(actor, allow))
if allow is None:
return True
return actor_matches_allow(actor, allow)
diff --git a/datasette/templates/database.html b/datasette/templates/database.html
index fc88003c..eaebfdf7 100644
--- a/datasette/templates/database.html
+++ b/datasette/templates/database.html
@@ -60,7 +60,7 @@
Queries
{% for query in queries %}
- - {{ query.title or query.name }} {% if query.requires_auth %} - requires authentication{% endif %}
+ - {{ query.title or query.name }} {% if query.private %} 🔒{% endif %}
{% endfor %}
{% endif %}
diff --git a/datasette/templates/index.html b/datasette/templates/index.html
index b394564a..3b8568b3 100644
--- a/datasette/templates/index.html
+++ b/datasette/templates/index.html
@@ -10,7 +10,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% for database in databases %}
- {{ database.name }}
+ {{ database.name }}{% if database.private %} 🔒{% endif %}
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.tables_count and database.hidden_tables_count %}, {% endif -%}
{% if database.hidden_tables_count -%}
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 961ab61e..4804b2a9 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -58,8 +58,7 @@ class DatabaseView(DataView):
tables.sort(key=lambda t: (t["hidden"], t["name"]))
canned_queries = [
dict(
- query,
- requires_auth=not actor_matches_allow(None, query.get("allow", None)),
+ query, private=not actor_matches_allow(None, query.get("allow", None)),
)
for query in self.ds.get_canned_queries(database)
if actor_matches_allow(
diff --git a/datasette/views/index.py b/datasette/views/index.py
index 5f903474..7b88028b 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -2,7 +2,7 @@ import hashlib
import json
from datasette.utils import CustomJSONEncoder
-from datasette.utils.asgi import Response
+from datasette.utils.asgi import Response, Forbidden
from datasette.version import __version__
from .base import BaseView
@@ -25,6 +25,22 @@ class IndexView(BaseView):
await self.check_permission(request, "view-instance")
databases = []
for name, db in self.ds.databases.items():
+ # Check permission
+ allowed = await self.ds.permission_allowed(
+ request.scope.get("actor"),
+ "view-database",
+ resource_type="database",
+ resource_identifier=name,
+ default=True,
+ )
+ if not allowed:
+ continue
+ private = not await self.ds.permission_allowed(
+ None,
+ "view-database",
+ resource_type="database",
+ resource_identifier=name,
+ )
table_names = await db.table_names()
hidden_table_names = set(await db.hidden_table_names())
views = await db.view_names()
@@ -95,6 +111,7 @@ class IndexView(BaseView):
),
"hidden_tables_count": len(hidden_tables),
"views_count": len(views),
+ "private": private,
}
)
diff --git a/tests/test_canned_write.py b/tests/test_canned_write.py
index c217be8f..dc3fba3f 100644
--- a/tests/test_canned_write.py
+++ b/tests/test_canned_write.py
@@ -120,13 +120,12 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
)
assert 200 == response.status
assert [
- {"name": "add_name", "requires_auth": False},
- {"name": "add_name_specify_id", "requires_auth": False},
- {"name": "delete_name", "requires_auth": True},
- {"name": "update_name", "requires_auth": False},
+ {"name": "add_name", "private": False},
+ {"name": "add_name_specify_id", "private": False},
+ {"name": "delete_name", "private": True},
+ {"name": "update_name", "private": False},
] == [
- {"name": q["name"], "requires_auth": q["requires_auth"]}
- for q in response.json["queries"]
+ {"name": q["name"], "private": q["private"]} for q in response.json["queries"]
]
diff --git a/tests/test_html.py b/tests/test_html.py
index e05640d7..3f6dc4df 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -207,10 +207,7 @@ def test_row_page_does_not_truncate():
assert response.status == 200
assert_permissions_checked(
client.ds,
- [
- "view-instance",
- ("view-table", "table", ("fixtures", "facetable")),
- ],
+ ["view-instance", ("view-table", "table", ("fixtures", "facetable")),],
)
table = Soup(response.body, "html.parser").find("table")
assert table["class"] == ["rows-and-columns"]
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index bf66bc9c..21014a25 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -40,3 +40,39 @@ def test_view_instance(allow, expected_anon, expected_auth):
path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
)
assert expected_auth == auth_response.status
+
+
+@pytest.mark.parametrize(
+ "allow,expected_anon,expected_auth",
+ [(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),],
+)
+def test_view_database(allow, expected_anon, expected_auth):
+ with make_app_client(
+ metadata={"databases": {"fixtures": {"allow": allow}}}
+ ) as client:
+ for path in (
+ "/fixtures",
+ "/fixtures/compound_three_primary_keys",
+ "/fixtures/compound_three_primary_keys/a,a,a",
+ ):
+ anon_response = client.get(path)
+ assert expected_anon == anon_response.status
+ auth_response = client.get(
+ path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ )
+ assert expected_auth == auth_response.status
+
+
+def test_database_list_respects_view_database():
+ with make_app_client(
+ metadata={"databases": {"fixtures": {"allow": {"id": "root"}}}},
+ extra_databases={"data.db": "create table names (name text)"},
+ ) as client:
+ anon_response = client.get("/")
+ assert 'data' in anon_response.text
+ assert 'fixtures' not in anon_response.text
+ auth_response = client.get(
+ "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ )
+ assert 'data' in auth_response.text
+ assert 'fixtures 🔒' in auth_response.text
From b26292a4582ea7fe16c59d0ac99f3bd8c3d4b1d0 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 20:56:49 -0700
Subject: [PATCH 0068/1871] Test that view-query is respected by query list,
refs #811
---
datasette/templates/database.html | 2 +-
tests/test_permissions.py | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/datasette/templates/database.html b/datasette/templates/database.html
index eaebfdf7..dfafc049 100644
--- a/datasette/templates/database.html
+++ b/datasette/templates/database.html
@@ -60,7 +60,7 @@
Queries
{% for query in queries %}
- - {{ query.title or query.name }} {% if query.private %} 🔒{% endif %}
+ - {{ query.title or query.name }}{% if query.private %} 🔒{% endif %}
{% endfor %}
{% endif %}
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 21014a25..e66b9291 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -22,6 +22,26 @@ def test_view_query(allow, expected_anon, expected_auth):
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),],
From 9397d718345c4b35d2a5c55bfcbd1468876b5ab9 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 7 Jun 2020 21:47:22 -0700
Subject: [PATCH 0069/1871] 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
From e18f8c3f871fe1e9e00554b5c6c75409cc1a5e6d Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 06:49:55 -0700
Subject: [PATCH 0070/1871] New check_visibility() utility function, refs #811
---
datasette/utils/__init__.py | 23 +++++++++++++++++++++++
datasette/views/database.py | 35 ++++++++++++++++-------------------
datasette/views/index.py | 19 ++++---------------
3 files changed, 43 insertions(+), 34 deletions(-)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 077728f4..3d964049 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -874,3 +874,26 @@ def actor_matches_allow(actor, allow):
if actor_values.intersection(values):
return True
return False
+
+
+async def check_visibility(
+ datasette, actor, action, resource_type, resource_identifier, default=True
+):
+ "Returns (visible, private) - visible = can you see it, private = can others see it too"
+ visible = await datasette.permission_allowed(
+ actor,
+ action,
+ resource_type=resource_type,
+ resource_identifier=resource_identifier,
+ default=default,
+ )
+ if not visible:
+ return (False, False)
+ private = not await datasette.permission_allowed(
+ None,
+ action,
+ resource_type=resource_type,
+ resource_identifier=resource_identifier,
+ default=default,
+ )
+ return visible, private
diff --git a/datasette/views/database.py b/datasette/views/database.py
index ba3d22d9..afbb6b05 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -3,6 +3,7 @@ import jinja2
from datasette.utils import (
actor_matches_allow,
+ check_visibility,
to_css_class,
validate_sql_select,
is_url,
@@ -42,21 +43,15 @@ class DatabaseView(DataView):
tables = []
for table in table_counts:
- allowed = await self.ds.permission_allowed(
+ visible, private = await check_visibility(
+ self.ds,
request.scope.get("actor"),
"view-table",
- resource_type="table",
- resource_identifier=(database, table),
- default=True,
+ "table",
+ (database, table),
)
- if not allowed:
+ if not visible:
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(
{
@@ -72,15 +67,17 @@ class DatabaseView(DataView):
)
tables.sort(key=lambda t: (t["hidden"], t["name"]))
- canned_queries = [
- dict(
- query, private=not actor_matches_allow(None, query.get("allow", None)),
+ canned_queries = []
+ for query in self.ds.get_canned_queries(database):
+ visible, private = await check_visibility(
+ self.ds,
+ request.scope.get("actor"),
+ "view-query",
+ "query",
+ (database, query["name"]),
)
- for query in self.ds.get_canned_queries(database)
- if actor_matches_allow(
- request.scope.get("actor", None), query.get("allow", None)
- )
- ]
+ if visible:
+ canned_queries.append(dict(query, private=private))
return (
{
"database": database,
diff --git a/datasette/views/index.py b/datasette/views/index.py
index 7b88028b..0f7fb613 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -1,7 +1,7 @@
import hashlib
import json
-from datasette.utils import CustomJSONEncoder
+from datasette.utils import check_visibility, CustomJSONEncoder
from datasette.utils.asgi import Response, Forbidden
from datasette.version import __version__
@@ -25,22 +25,11 @@ class IndexView(BaseView):
await self.check_permission(request, "view-instance")
databases = []
for name, db in self.ds.databases.items():
- # Check permission
- allowed = await self.ds.permission_allowed(
- request.scope.get("actor"),
- "view-database",
- resource_type="database",
- resource_identifier=name,
- default=True,
+ visible, private = await check_visibility(
+ self.ds, request.scope.get("actor"), "view-database", "database", name,
)
- if not allowed:
+ if not visible:
continue
- private = not await self.ds.permission_allowed(
- None,
- "view-database",
- resource_type="database",
- resource_identifier=name,
- )
table_names = await db.table_names()
hidden_table_names = set(await db.hidden_table_names())
views = await db.view_names()
From cc218fa9be55842656d030545c308392e3736053 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 07:02:31 -0700
Subject: [PATCH 0071/1871] Move assert_permissions_checked() calls from
test_html.py to test_permissions.py, refs #811
---
datasette/app.py | 2 +-
tests/test_html.py | 49 ------------------------------------
tests/test_permissions.py | 52 ++++++++++++++++++++++++++++++++++++++-
3 files changed, 52 insertions(+), 51 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index f433a10a..23c293c9 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -298,7 +298,7 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self._register_renderers()
- self._permission_checks = collections.deque(maxlen=30)
+ self._permission_checks = collections.deque(maxlen=200)
self._root_token = os.urandom(32).hex()
def sign(self, value, namespace="default"):
diff --git a/tests/test_html.py b/tests/test_html.py
index 3f6dc4df..cb0e0c90 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -4,7 +4,6 @@ from .fixtures import ( # noqa
app_client_shorter_time_limit,
app_client_two_attached_databases,
app_client_with_hash,
- assert_permissions_checked,
make_app_client,
METADATA,
)
@@ -18,7 +17,6 @@ import urllib.parse
def test_homepage(app_client_two_attached_databases):
response = app_client_two_attached_databases.get("/")
- assert_permissions_checked(app_client_two_attached_databases.ds, ["view-instance"])
assert response.status == 200
assert "text/html; charset=utf-8" == response.headers["content-type"]
soup = Soup(response.body, "html.parser")
@@ -77,9 +75,6 @@ def test_static_mounts():
def test_memory_database_page():
with make_app_client(memory=True) as client:
response = client.get("/:memory:")
- assert_permissions_checked(
- client.ds, ["view-instance", ("view-database", "database", ":memory:")]
- )
assert response.status == 200
@@ -92,9 +87,6 @@ def test_database_page_redirects_with_url_hash(app_client_with_hash):
def test_database_page(app_client):
response = app_client.get("/fixtures")
- assert_permissions_checked(
- app_client.ds, ["view-instance", ("view-database", "database", "fixtures")]
- )
soup = Soup(response.body, "html.parser")
queries_ul = soup.find("h2", text="Queries").find_next_sibling("ul")
assert queries_ul is not None
@@ -205,10 +197,6 @@ def test_row_page_does_not_truncate():
with make_app_client(config={"truncate_cells_html": 5}) as client:
response = client.get("/fixtures/facetable/1")
assert response.status == 200
- assert_permissions_checked(
- client.ds,
- ["view-instance", ("view-table", "table", ("fixtures", "facetable")),],
- )
table = Soup(response.body, "html.parser").find("table")
assert table["class"] == ["rows-and-columns"]
assert ["Mission"] == [
@@ -518,14 +506,6 @@ def test_templates_considered(app_client, path, expected_considered):
def test_table_html_simple_primary_key(app_client):
response = app_client.get("/fixtures/simple_primary_key?_size=3")
- assert_permissions_checked(
- app_client.ds,
- [
- "view-instance",
- ("view-database", "database", "fixtures"),
- ("view-table", "table", ("fixtures", "simple_primary_key")),
- ],
- )
assert response.status == 200
table = Soup(response.body, "html.parser").find("table")
assert table["class"] == ["rows-and-columns"]
@@ -881,19 +861,6 @@ def test_database_metadata(app_client):
assert_footer_links(soup)
-def test_database_query_permission_checks(app_client):
- response = app_client.get("/fixtures?sql=select+1")
- assert response.status == 200
- assert_permissions_checked(
- app_client.ds,
- [
- "view-instance",
- ("view-database", "database", "fixtures"),
- ("execute-sql", "database", "fixtures"),
- ],
- )
-
-
def test_database_metadata_with_custom_sql(app_client):
response = app_client.get("/fixtures?sql=select+*+from+simple_primary_key")
assert response.status == 200
@@ -929,14 +896,6 @@ def test_database_download_allowed_for_immutable():
assert len(soup.findAll("a", {"href": re.compile(r"\.db$")}))
# Check we can actually download it
assert 200 == client.get("/fixtures.db").status
- assert_permissions_checked(
- client.ds,
- [
- "view-instance",
- ("view-database", "database", "fixtures"),
- ("view-database-download", "database", "fixtures"),
- ],
- )
def test_database_download_disallowed_for_mutable(app_client):
@@ -1032,14 +991,6 @@ def test_404_content_type(app_client):
def test_canned_query_with_custom_metadata(app_client):
response = app_client.get("/fixtures/neighborhood_search?text=town")
- assert_permissions_checked(
- app_client.ds,
- [
- "view-instance",
- ("view-database", "database", "fixtures"),
- ("view-query", "query", ("fixtures", "neighborhood_search")),
- ],
- )
assert response.status == 200
soup = Soup(response.body, "html.parser")
assert "Search neighborhoods" == soup.find("h1").text
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 7c5b02c0..df905aa1 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -1,4 +1,4 @@
-from .fixtures import make_app_client
+from .fixtures import app_client, assert_permissions_checked, make_app_client
import pytest
@@ -139,3 +139,53 @@ def test_query_list_respects_view_query():
"/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
)
assert html_fragment in auth_response.text
+
+
+@pytest.mark.parametrize(
+ "path,permissions",
+ [
+ ("/", ["view-instance"]),
+ ("/fixtures", ["view-instance", ("view-database", "database", "fixtures")]),
+ (
+ "/fixtures/facetable/1",
+ ["view-instance", ("view-table", "table", ("fixtures", "facetable"))],
+ ),
+ (
+ "/fixtures/simple_primary_key",
+ [
+ "view-instance",
+ ("view-database", "database", "fixtures"),
+ ("view-table", "table", ("fixtures", "simple_primary_key")),
+ ],
+ ),
+ (
+ "/fixtures?sql=select+1",
+ [
+ "view-instance",
+ ("view-database", "database", "fixtures"),
+ ("execute-sql", "database", "fixtures"),
+ ],
+ ),
+ (
+ "/fixtures.db",
+ [
+ "view-instance",
+ ("view-database", "database", "fixtures"),
+ ("view-database-download", "database", "fixtures"),
+ ],
+ ),
+ (
+ "/fixtures/neighborhood_search",
+ [
+ "view-instance",
+ ("view-database", "database", "fixtures"),
+ ("view-query", "query", ("fixtures", "neighborhood_search")),
+ ],
+ ),
+ ],
+)
+def test_permissions_checked(app_client, path, permissions):
+ app_client.ds._permission_checks.clear()
+ response = app_client.get(path)
+ assert response.status in (200, 403)
+ assert_permissions_checked(app_client.ds, permissions)
From 1cf86e5eccf3f92b483bacbad860879cf39b0ad6 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 07:18:37 -0700
Subject: [PATCH 0072/1871] Show padlock on private index page, refs #811
---
datasette/templates/index.html | 2 +-
datasette/views/index.py | 3 +++
tests/test_permissions.py | 6 ++++++
3 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/datasette/templates/index.html b/datasette/templates/index.html
index 3b8568b3..5a8dccae 100644
--- a/datasette/templates/index.html
+++ b/datasette/templates/index.html
@@ -5,7 +5,7 @@
{% block body_class %}index{% endblock %}
{% block content %}
-{{ metadata.title or "Datasette" }}
+{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
diff --git a/datasette/views/index.py b/datasette/views/index.py
index 0f7fb613..8cbe28f0 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -121,5 +121,8 @@ class IndexView(BaseView):
"databases": databases,
"metadata": self.ds.metadata(),
"datasette_version": __version__,
+ "private": not await self.ds.permission_allowed(
+ None, "view-instance"
+ ),
},
)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index df905aa1..5dcf46ad 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -16,10 +16,16 @@ def test_view_instance(allow, expected_anon, expected_auth):
):
anon_response = client.get(path)
assert expected_anon == anon_response.status
+ if allow and path == "/" and anon_response.status == 200:
+ # Should be no padlock
+ assert "Datasette 🔒
" not in anon_response.text
auth_response = client.get(
path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
)
assert expected_auth == auth_response.status
+ # Check for the padlock
+ if allow and path == "/" and expected_anon == 403 and expected_auth == 200:
+ assert "Datasette 🔒
" in auth_response.text
@pytest.mark.parametrize(
From 3ce7f2e7dae010de97b67618c111ea5853164a69 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 07:23:10 -0700
Subject: [PATCH 0073/1871] Show padlock on private database page, refs #811
---
datasette/templates/database.html | 2 +-
datasette/views/database.py | 3 +++
tests/test_permissions.py | 10 ++++++++++
3 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/datasette/templates/database.html b/datasette/templates/database.html
index 1187267d..089142e2 100644
--- a/datasette/templates/database.html
+++ b/datasette/templates/database.html
@@ -18,7 +18,7 @@
{% block content %}
-{{ metadata.title or database }}
+{{ metadata.title or database }}{% if private %} 🔒{% endif %}
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
diff --git a/datasette/views/database.py b/datasette/views/database.py
index afbb6b05..2d7e6b31 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -86,6 +86,9 @@ class DatabaseView(DataView):
"hidden_count": len([t for t in tables if t["hidden"]]),
"views": views,
"queries": canned_queries,
+ "private": not await self.ds.permission_allowed(
+ None, "view-database", "database", database
+ ),
},
{
"show_hidden": request.args.get("_show_hidden"),
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 5dcf46ad..d76d1e15 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -43,10 +43,20 @@ def test_view_database(allow, expected_anon, expected_auth):
):
anon_response = client.get(path)
assert expected_anon == anon_response.status
+ if allow and path == "/fixtures" and anon_response.status == 200:
+ # Should be no padlock
+ assert ">fixtures 🔒" not in anon_response.text
auth_response = client.get(
path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
)
assert expected_auth == auth_response.status
+ if (
+ allow
+ and path == "/fixtures"
+ and expected_anon == 403
+ and expected_auth == 200
+ ):
+ assert ">fixtures 🔒" in auth_response.text
def test_database_list_respects_view_database():
From 2a8b39800f194925658bd9e1b5e4cc12619d5e9c Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 07:50:06 -0700
Subject: [PATCH 0074/1871] Updated tests, refs #811
---
tests/test_api.py | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/tests/test_api.py b/tests/test_api.py
index 22378946..13a98b6a 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -70,6 +70,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "Table With Space In Name",
@@ -79,6 +80,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "attraction_characteristic",
@@ -97,6 +99,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "binary_data",
@@ -106,6 +109,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "complex_foreign_keys",
@@ -134,6 +138,7 @@ def test_database_page(app_client):
},
],
},
+ "private": False,
},
{
"name": "compound_primary_key",
@@ -143,6 +148,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "compound_three_primary_keys",
@@ -152,6 +158,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "custom_foreign_key_label",
@@ -170,6 +177,7 @@ def test_database_page(app_client):
}
],
},
+ "private": False,
},
{
"name": "facet_cities",
@@ -188,6 +196,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "facetable",
@@ -217,6 +226,7 @@ def test_database_page(app_client):
}
],
},
+ "private": False,
},
{
"name": "foreign_key_references",
@@ -240,6 +250,7 @@ def test_database_page(app_client):
},
],
},
+ "private": False,
},
{
"name": "infinity",
@@ -249,6 +260,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "primary_key_multiple_columns",
@@ -267,6 +279,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "primary_key_multiple_columns_explicit_label",
@@ -285,6 +298,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "roadside_attraction_characteristics",
@@ -308,6 +322,7 @@ def test_database_page(app_client):
},
],
},
+ "private": False,
},
{
"name": "roadside_attractions",
@@ -326,6 +341,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "searchable",
@@ -344,6 +360,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "searchable_tags",
@@ -363,6 +380,7 @@ def test_database_page(app_client):
},
],
},
+ "private": False,
},
{
"name": "select",
@@ -372,6 +390,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "simple_primary_key",
@@ -405,6 +424,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "sortable",
@@ -422,6 +442,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "table/with/slashes.csv",
@@ -431,6 +452,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "tags",
@@ -449,6 +471,7 @@ def test_database_page(app_client):
],
"outgoing": [],
},
+ "private": False,
},
{
"name": "units",
@@ -458,6 +481,7 @@ def test_database_page(app_client):
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "no_primary_key",
@@ -467,6 +491,7 @@ def test_database_page(app_client):
"hidden": True,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "searchable_fts",
@@ -476,6 +501,7 @@ def test_database_page(app_client):
"hidden": True,
"fts_table": "searchable_fts",
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "searchable_fts_content",
@@ -491,6 +517,7 @@ def test_database_page(app_client):
"hidden": True,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "searchable_fts_segdir",
@@ -507,6 +534,7 @@ def test_database_page(app_client):
"hidden": True,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
{
"name": "searchable_fts_segments",
@@ -516,6 +544,7 @@ def test_database_page(app_client):
"hidden": True,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
+ "private": False,
},
] == data["tables"]
@@ -537,6 +566,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
"tables_and_views_more": False,
"tables_and_views_truncated": [],
"views_count": 0,
+ "private": False,
}
} == response.json
# Try that SQL query
From 177059284dc953e6c76f86213aa470db2ff3eaca Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 10:05:32 -0700
Subject: [PATCH 0075/1871] New request.actor property, refs #811
---
datasette/app.py | 2 +-
datasette/utils/asgi.py | 4 ++++
datasette/views/base.py | 2 +-
datasette/views/database.py | 4 ++--
datasette/views/index.py | 2 +-
datasette/views/special.py | 2 +-
docs/authentication.rst | 2 ++
docs/internals.rst | 5 ++++-
8 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 23c293c9..87e542c1 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -667,7 +667,7 @@ class Datasette:
return d
def _actor(self, request):
- return {"actor": request.scope.get("actor", None)}
+ return {"actor": request.actor}
def table_metadata(self, database, table):
"Fetch table-specific metadata."
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index fa78c8df..bca9c9ab 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -74,6 +74,10 @@ class Request:
def args(self):
return MultiParams(parse_qs(qs=self.query_string))
+ @property
+ def actor(self):
+ return self.scope.get("actor", None)
+
async def post_vars(self):
body = []
body = b""
diff --git a/datasette/views/base.py b/datasette/views/base.py
index 9c2cbbcc..000d354b 100644
--- a/datasette/views/base.py
+++ b/datasette/views/base.py
@@ -68,7 +68,7 @@ class BaseView(AsgiView):
self, request, action, resource_type=None, resource_identifier=None
):
ok = await self.ds.permission_allowed(
- request.scope.get("actor"),
+ request.actor,
action,
resource_type=resource_type,
resource_identifier=resource_identifier,
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 2d7e6b31..dee6c9c8 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -45,7 +45,7 @@ class DatabaseView(DataView):
for table in table_counts:
visible, private = await check_visibility(
self.ds,
- request.scope.get("actor"),
+ request.actor,
"view-table",
"table",
(database, table),
@@ -71,7 +71,7 @@ class DatabaseView(DataView):
for query in self.ds.get_canned_queries(database):
visible, private = await check_visibility(
self.ds,
- request.scope.get("actor"),
+ request.actor,
"view-query",
"query",
(database, query["name"]),
diff --git a/datasette/views/index.py b/datasette/views/index.py
index 8cbe28f0..609bfa6a 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -26,7 +26,7 @@ class IndexView(BaseView):
databases = []
for name, db in self.ds.databases.items():
visible, private = await check_visibility(
- self.ds, request.scope.get("actor"), "view-database", "database", name,
+ self.ds, request.actor, "view-database", "database", name,
)
if not visible:
continue
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 37c04697..b8bd57c6 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -86,7 +86,7 @@ class PermissionsDebugView(BaseView):
async def get(self, request):
if not await self.ds.permission_allowed(
- request.scope.get("actor"), "permissions-debug"
+ request.actor, "permissions-debug"
):
return Response("Permission denied", status=403)
return await self.render(
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 2caca66f..bda6a0b7 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -140,6 +140,8 @@ Plugins that wish to implement the same permissions scheme as canned queries can
actor_matches_allow({"id": "root"}, {"id": "*"})
# returns True
+The currently authenticated actor is made available to plugins as ``request.actor``.
+
.. _PermissionsDebugView:
Permissions Debug
diff --git a/docs/internals.rst b/docs/internals.rst
index 25b2d875..7498f017 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -42,6 +42,9 @@ The request object is passed to various plugin hooks. It represents an incoming
``.args`` - MultiParams
An object representing the parsed querystring parameters, see below.
+``.actor`` - dictionary (str -> Any) or None
+ The currently authenticated actor (see :ref:`actors `), or ``None`` if the request is unauthenticated.
+
The object also has one awaitable method:
``await request.post_vars()`` - dictionary
@@ -122,7 +125,7 @@ await .permission_allowed(actor, action, resource_type=None, resource_identifier
-----------------------------------------------------------------------------------------------------
``actor`` - dictionary
- The authenticated actor. This is usually ``request.scope.get("actor")``.
+ The authenticated actor. This is usually ``request.actor``.
``action`` - string
The name of the action that is being permission checked.
From ab14b20b248dafbe7f9f9487985614939c83b517 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 10:16:24 -0700
Subject: [PATCH 0076/1871] Get tests working again
---
datasette/views/database.py | 6 +-----
datasette/views/index.py | 2 +-
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/datasette/views/database.py b/datasette/views/database.py
index dee6c9c8..6f6404a7 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -44,11 +44,7 @@ class DatabaseView(DataView):
tables = []
for table in table_counts:
visible, private = await check_visibility(
- self.ds,
- request.actor,
- "view-table",
- "table",
- (database, table),
+ self.ds, request.actor, "view-table", "table", (database, table),
)
if not visible:
continue
diff --git a/datasette/views/index.py b/datasette/views/index.py
index 609bfa6a..59d3e042 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -122,7 +122,7 @@ class IndexView(BaseView):
"metadata": self.ds.metadata(),
"datasette_version": __version__,
"private": not await self.ds.permission_allowed(
- None, "view-instance"
+ None, "view-instance", default=True
),
},
)
From dfff34e1987976e72f58ee7b274952840b1f4b71 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:03:33 -0700
Subject: [PATCH 0077/1871] Applied black, refs #811
---
datasette/views/special.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/datasette/views/special.py b/datasette/views/special.py
index b8bd57c6..7a5fbe21 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -85,9 +85,7 @@ class PermissionsDebugView(BaseView):
self.ds = datasette
async def get(self, request):
- if not await self.ds.permission_allowed(
- request.actor, "permissions-debug"
- ):
+ if not await self.ds.permission_allowed(request.actor, "permissions-debug"):
return Response("Permission denied", status=403)
return await self.render(
["permissions_debug.html"],
From aa420009c08921d0c9a68cf60a57959be0e8a2e5 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:07:11 -0700
Subject: [PATCH 0078/1871] Show padlock on private table page, refs #811
---
datasette/templates/table.html | 2 +-
datasette/views/table.py | 5 +++++
tests/test_permissions.py | 5 +++++
3 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/datasette/templates/table.html b/datasette/templates/table.html
index fa6766a8..1289e125 100644
--- a/datasette/templates/table.html
+++ b/datasette/templates/table.html
@@ -26,7 +26,7 @@
{% block content %}
-{{ metadata.title or table }}{% if is_view %} (view){% endif %}
+{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 935fed3d..cd952568 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -271,6 +271,10 @@ class TableView(RowTableShared):
await self.check_permission(request, "view-database", "database", database)
await self.check_permission(request, "view-table", "table", (database, table))
+ private = not await self.ds.permission_allowed(
+ None, "view-table", "table", (database, table), default=True
+ )
+
pks = await db.primary_keys(table)
table_columns = await db.table_columns(table)
@@ -834,6 +838,7 @@ class TableView(RowTableShared):
"suggested_facets": suggested_facets,
"next": next_value and str(next_value) or None,
"next_url": next_url,
+ "private": private,
},
extra_template,
(
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index d76d1e15..733afd5f 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -90,11 +90,16 @@ def test_view_table(allow, expected_anon, expected_auth):
) as client:
anon_response = client.get("/fixtures/compound_three_primary_keys")
assert expected_anon == anon_response.status
+ if allow and anon_response.status == 200:
+ # Should be no padlock
+ assert ">compound_three_primary_keys 🔒" not in anon_response.text
auth_response = client.get(
"/fixtures/compound_three_primary_keys",
cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
)
assert expected_auth == auth_response.status
+ if allow and expected_anon == 403 and expected_auth == 200:
+ assert ">compound_three_primary_keys 🔒" in auth_response.text
def test_table_list_respects_view_table():
From 9ac27f67fe346e753b562b711a2086e4c616d51d Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:13:32 -0700
Subject: [PATCH 0079/1871] Show padlock on private query page, refs #811
---
datasette/templates/query.html | 2 +-
datasette/views/database.py | 6 ++++++
tests/test_permissions.py | 5 +++++
3 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/datasette/templates/query.html b/datasette/templates/query.html
index a7cb6647..7771b101 100644
--- a/datasette/templates/query.html
+++ b/datasette/templates/query.html
@@ -28,7 +28,7 @@
{% block content %}
-{{ metadata.title or database }}
+{{ metadata.title or database }}{% if private %} 🔒{% endif %}
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 6f6404a7..30817106 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -147,10 +147,14 @@ class QueryView(DataView):
# Respect canned query permissions
await self.check_permission(request, "view-instance")
await self.check_permission(request, "view-database", "database", database)
+ private = False
if canned_query:
await self.check_permission(
request, "view-query", "query", (database, canned_query)
)
+ private = not await self.ds.permission_allowed(
+ None, "view-query", "query", (database, canned_query), default=True
+ )
else:
await self.check_permission(request, "execute-sql", "database", database)
# Extract any :named parameters
@@ -214,6 +218,7 @@ class QueryView(DataView):
"truncated": False,
"columns": [],
"query": {"sql": sql, "params": params},
+ "private": private,
},
extra_template,
templates,
@@ -282,6 +287,7 @@ class QueryView(DataView):
"truncated": results.truncated,
"columns": columns,
"query": {"sql": sql, "params": params},
+ "private": private,
},
extra_template,
templates,
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 733afd5f..55b2d673 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -136,10 +136,15 @@ def test_view_query(allow, expected_anon, expected_auth):
) as client:
anon_response = client.get("/fixtures/q")
assert expected_anon == anon_response.status
+ if allow and anon_response.status == 200:
+ # Should be no padlock
+ assert ">fixtures 🔒" not in anon_response.text
auth_response = client.get(
"/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
)
assert expected_auth == auth_response.status
+ if allow and expected_anon == 403 and expected_auth == 200:
+ assert ">fixtures 🔒" in auth_response.text
def test_query_list_respects_view_query():
From dcec89270a2e3b9fabed93f1d7b9be3ef86e9ed2 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:20:21 -0700
Subject: [PATCH 0080/1871] View list respects view-table permission, refs #811
Also makes a small change to the /fixtures.json JSON:
"views": ["view_name"]
Is now:
"views": [{"name": "view_name", "private": true}]
---
datasette/templates/database.html | 2 +-
datasette/views/database.py | 11 ++++++++++-
tests/test_permissions.py | 18 +++++++++++++-----
3 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/datasette/templates/database.html b/datasette/templates/database.html
index 089142e2..100faee4 100644
--- a/datasette/templates/database.html
+++ b/datasette/templates/database.html
@@ -51,7 +51,7 @@
Views
{% for view in views %}
- - {{ view }}
+ - {{ view.name }}{% if view.private %} 🔒{% endif %}
{% endfor %}
{% endif %}
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 30817106..824cb632 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -37,10 +37,19 @@ class DatabaseView(DataView):
db = self.ds.databases[database]
table_counts = await db.table_counts(5)
- views = await db.view_names()
hidden_table_names = set(await db.hidden_table_names())
all_foreign_keys = await db.get_all_foreign_keys()
+ views = []
+ for view_name in await db.view_names():
+ visible, private = await check_visibility(
+ self.ds, request.actor, "view-table", "table", (database, view_name),
+ )
+ if visible:
+ views.append(
+ {"name": view_name, "private": private,}
+ )
+
tables = []
for table in table_counts:
visible, private = await check_visibility(
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 55b2d673..5c338e04 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -107,19 +107,27 @@ def test_table_list_respects_view_table():
metadata={
"databases": {
"fixtures": {
- "tables": {"compound_three_primary_keys": {"allow": {"id": "root"}}}
+ "tables": {
+ "compound_three_primary_keys": {"allow": {"id": "root"}},
+ # And a SQL view too:
+ "paginated_view": {"allow": {"id": "root"}},
+ }
}
}
}
) as client:
- html_fragment = 'compound_three_primary_keys 🔒'
+ html_fragments = [
+ ">compound_three_primary_keys 🔒",
+ ">paginated_view 🔒",
+ ]
anon_response = client.get("/fixtures")
- assert html_fragment not in anon_response.text
- assert '"/fixtures/compound_three_primary_keys"' not in anon_response.text
+ for html_fragment in html_fragments:
+ assert html_fragment 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
+ for html_fragment in html_fragments:
+ assert html_fragment in auth_response.text
@pytest.mark.parametrize(
From 5598c5de011db95396b65b5c8c251cbe6884d6ae Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:34:14 -0700
Subject: [PATCH 0081/1871] Database list on index page respects table/view
permissions, refs #811
---
datasette/templates/index.html | 2 +-
datasette/views/index.py | 25 ++++++++++++++++++++-----
tests/test_permissions.py | 31 +++++++++++++++++++++++++++++++
3 files changed, 52 insertions(+), 6 deletions(-)
diff --git a/datasette/templates/index.html b/datasette/templates/index.html
index 5a8dccae..c1adfc59 100644
--- a/datasette/templates/index.html
+++ b/datasette/templates/index.html
@@ -22,7 +22,7 @@
{% endif %}
{% for table in database.tables_and_views_truncated %}{{ table.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, ...{% endif %}
+ }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, ...{% endif %}
{% endfor %}
{% endblock %}
diff --git a/datasette/views/index.py b/datasette/views/index.py
index 59d3e042..a3e8388c 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -25,14 +25,22 @@ class IndexView(BaseView):
await self.check_permission(request, "view-instance")
databases = []
for name, db in self.ds.databases.items():
- visible, private = await check_visibility(
+ visible, database_private = await check_visibility(
self.ds, request.actor, "view-database", "database", name,
)
if not visible:
continue
table_names = await db.table_names()
hidden_table_names = set(await db.hidden_table_names())
- views = await db.view_names()
+
+ views = []
+ for view_name in await db.view_names():
+ visible, private = await check_visibility(
+ self.ds, request.actor, "view-table", "table", (name, view_name),
+ )
+ if visible:
+ views.append({"name": view_name, "private": private})
+
# Perform counts only for immutable or DBS with <= COUNT_TABLE_LIMIT tables
table_counts = {}
if not db.is_mutable or db.size < COUNT_DB_SIZE_LIMIT:
@@ -40,8 +48,14 @@ class IndexView(BaseView):
# If any of these are None it means at least one timed out - ignore them all
if any(v is None for v in table_counts.values()):
table_counts = {}
+
tables = {}
for table in table_names:
+ visible, private = await check_visibility(
+ self.ds, request.actor, "view-table", "table", (name, table),
+ )
+ if not visible:
+ continue
table_columns = await db.table_columns(table)
tables[table] = {
"name": table,
@@ -51,6 +65,7 @@ class IndexView(BaseView):
"hidden": table in hidden_table_names,
"fts_table": await db.fts_table(table),
"num_relationships_for_sorting": 0,
+ "private": private,
}
if request.args.get("_sort") == "relationships" or not table_counts:
@@ -78,8 +93,8 @@ class IndexView(BaseView):
# Only add views if this is less than TRUNCATE_AT
if len(tables_and_views_truncated) < TRUNCATE_AT:
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
- for view_name in views[:num_views_to_add]:
- tables_and_views_truncated.append({"name": view_name})
+ for view in views[:num_views_to_add]:
+ tables_and_views_truncated.append(view)
databases.append(
{
@@ -100,7 +115,7 @@ class IndexView(BaseView):
),
"hidden_tables_count": len(hidden_tables),
"views_count": len(views),
- "private": private,
+ "private": database_private,
}
)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 5c338e04..475f93dd 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -74,6 +74,37 @@ def test_database_list_respects_view_database():
assert 'fixtures 🔒' in auth_response.text
+def test_database_list_respects_view_table():
+ with make_app_client(
+ metadata={
+ "databases": {
+ "data": {
+ "tables": {
+ "names": {"allow": {"id": "root"}},
+ "v": {"allow": {"id": "root"}},
+ }
+ }
+ }
+ },
+ extra_databases={
+ "data.db": "create table names (name text); create view v as select * from names"
+ },
+ ) as client:
+ html_fragments = [
+ ">names 🔒",
+ ">v 🔒",
+ ]
+ anon_response_text = client.get("/").text
+ assert "0 rows in 0 tables" in anon_response_text
+ for html_fragment in html_fragments:
+ assert html_fragment not in anon_response_text
+ auth_response_text = client.get(
+ "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ ).text
+ for html_fragment in html_fragments:
+ assert html_fragment in auth_response_text
+
+
@pytest.mark.parametrize(
"allow,expected_anon,expected_auth",
[(None, 200, 200), ({}, 403, 403), ({"id": "root"}, 403, 200),],
From c9f1ec616e5a8c83f554baaedd38663569fb9b91 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:51:03 -0700
Subject: [PATCH 0082/1871] Removed resource_type from permissions system,
closes #817
Refs #811, #699
---
datasette/app.py | 4 +---
datasette/default_permissions.py | 5 +---
datasette/hookspecs.py | 2 +-
datasette/templates/permissions_debug.html | 4 ++--
datasette/utils/__init__.py | 16 +++----------
datasette/views/base.py | 5 +---
datasette/views/database.py | 28 ++++++++--------------
datasette/views/index.py | 6 ++---
datasette/views/table.py | 10 ++++----
docs/authentication.rst | 19 ++-------------
docs/internals.rst | 7 ++----
docs/plugins.rst | 9 +++----
tests/conftest.py | 4 ++--
tests/fixtures.py | 9 +++----
14 files changed, 39 insertions(+), 89 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 87e542c1..c12e0af0 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -465,7 +465,7 @@ class Datasette:
return []
async def permission_allowed(
- self, actor, action, resource_type=None, resource_identifier=None, default=False
+ self, actor, action, resource_identifier=None, default=False
):
"Check permissions using the permissions_allowed plugin hook"
result = None
@@ -473,7 +473,6 @@ class Datasette:
datasette=self,
actor=actor,
action=action,
- resource_type=resource_type,
resource_identifier=resource_identifier,
):
if callable(check):
@@ -491,7 +490,6 @@ class Datasette:
"when": datetime.datetime.utcnow().isoformat(),
"actor": actor,
"action": action,
- "resource_type": resource_type,
"resource_identifier": resource_identifier,
"used_default": used_default,
"result": result,
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index dd1770a3..d27704aa 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -3,7 +3,7 @@ from datasette.utils import actor_matches_allow
@hookimpl
-def permission_allowed(datasette, actor, action, resource_type, resource_identifier):
+def permission_allowed(datasette, actor, action, resource_identifier):
if action == "permissions-debug":
if actor and actor.get("id") == "root":
return True
@@ -12,13 +12,11 @@ def permission_allowed(datasette, actor, action, resource_type, resource_identif
if allow is not None:
return actor_matches_allow(actor, allow)
elif action == "view-database":
- assert resource_type == "database"
database_allow = datasette.metadata("allow", database=resource_identifier)
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")
@@ -27,7 +25,6 @@ def permission_allowed(datasette, actor, action, resource_type, resource_identif
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"
database, query_name = resource_identifier
queries_metadata = datasette.metadata("queries", database=database)
assert query_name in queries_metadata
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 71d06661..3c202553 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -66,5 +66,5 @@ def actor_from_request(datasette, request):
@hookspec
-def permission_allowed(datasette, actor, action, resource_type, resource_identifier):
+def permission_allowed(datasette, actor, action, resource_identifier):
"Check if actor is allowed to perfom this action - return True, False or None"
diff --git a/datasette/templates/permissions_debug.html b/datasette/templates/permissions_debug.html
index dda57dfa..7d3ee712 100644
--- a/datasette/templates/permissions_debug.html
+++ b/datasette/templates/permissions_debug.html
@@ -46,8 +46,8 @@
{% endif %}
Actor: {{ check.actor|tojson }}
- {% if check.resource_type %}
- Resource: {{ check.resource_type }} = {{ check.resource_identifier }}
+ {% if check.resource_identifier %}
+ Resource: {{ check.resource_identifier }}
{% endif %}
{% endfor %}
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 3d964049..257d1285 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -876,24 +876,14 @@ def actor_matches_allow(actor, allow):
return False
-async def check_visibility(
- datasette, actor, action, resource_type, resource_identifier, default=True
-):
+async def check_visibility(datasette, actor, action, resource_identifier, default=True):
"Returns (visible, private) - visible = can you see it, private = can others see it too"
visible = await datasette.permission_allowed(
- actor,
- action,
- resource_type=resource_type,
- resource_identifier=resource_identifier,
- default=default,
+ actor, action, resource_identifier=resource_identifier, default=default,
)
if not visible:
return (False, False)
private = not await datasette.permission_allowed(
- None,
- action,
- resource_type=resource_type,
- resource_identifier=resource_identifier,
- default=default,
+ None, action, resource_identifier=resource_identifier, default=default,
)
return visible, private
diff --git a/datasette/views/base.py b/datasette/views/base.py
index 000d354b..2ca5e86a 100644
--- a/datasette/views/base.py
+++ b/datasette/views/base.py
@@ -64,13 +64,10 @@ class BaseView(AsgiView):
response.body = b""
return response
- async def check_permission(
- self, request, action, resource_type=None, resource_identifier=None
- ):
+ async def check_permission(self, request, action, resource_identifier=None):
ok = await self.ds.permission_allowed(
request.actor,
action,
- resource_type=resource_type,
resource_identifier=resource_identifier,
default=True,
)
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 824cb632..d562ecb1 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -21,7 +21,7 @@ class DatabaseView(DataView):
async def data(self, request, database, hash, default_labels=False, _size=None):
await self.check_permission(request, "view-instance")
- await self.check_permission(request, "view-database", "database", database)
+ await self.check_permission(request, "view-database", database)
metadata = (self.ds.metadata("databases") or {}).get(database, {})
self.ds.update_with_inherited_metadata(metadata)
@@ -43,7 +43,7 @@ class DatabaseView(DataView):
views = []
for view_name in await db.view_names():
visible, private = await check_visibility(
- self.ds, request.actor, "view-table", "table", (database, view_name),
+ self.ds, request.actor, "view-table", (database, view_name),
)
if visible:
views.append(
@@ -53,7 +53,7 @@ class DatabaseView(DataView):
tables = []
for table in table_counts:
visible, private = await check_visibility(
- self.ds, request.actor, "view-table", "table", (database, table),
+ self.ds, request.actor, "view-table", (database, table),
)
if not visible:
continue
@@ -75,11 +75,7 @@ class DatabaseView(DataView):
canned_queries = []
for query in self.ds.get_canned_queries(database):
visible, private = await check_visibility(
- self.ds,
- request.actor,
- "view-query",
- "query",
- (database, query["name"]),
+ self.ds, request.actor, "view-query", (database, query["name"]),
)
if visible:
canned_queries.append(dict(query, private=private))
@@ -112,10 +108,8 @@ class DatabaseDownload(DataView):
async def view_get(self, request, database, hash, correct_hash_present, **kwargs):
await self.check_permission(request, "view-instance")
- await self.check_permission(request, "view-database", "database", database)
- await self.check_permission(
- request, "view-database-download", "database", database
- )
+ await self.check_permission(request, "view-database", database)
+ await self.check_permission(request, "view-database-download", database)
if database not in self.ds.databases:
raise DatasetteError("Invalid database", status=404)
db = self.ds.databases[database]
@@ -155,17 +149,15 @@ class QueryView(DataView):
# Respect canned query permissions
await self.check_permission(request, "view-instance")
- await self.check_permission(request, "view-database", "database", database)
+ await self.check_permission(request, "view-database", database)
private = False
if canned_query:
- await self.check_permission(
- request, "view-query", "query", (database, canned_query)
- )
+ await self.check_permission(request, "view-query", (database, canned_query))
private = not await self.ds.permission_allowed(
- None, "view-query", "query", (database, canned_query), default=True
+ None, "view-query", (database, canned_query), default=True
)
else:
- await self.check_permission(request, "execute-sql", "database", database)
+ await self.check_permission(request, "execute-sql", database)
# Extract any :named parameters
named_parameters = named_parameters or self.re_named_parameter.findall(sql)
named_parameter_values = {
diff --git a/datasette/views/index.py b/datasette/views/index.py
index a3e8388c..b2706251 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -26,7 +26,7 @@ class IndexView(BaseView):
databases = []
for name, db in self.ds.databases.items():
visible, database_private = await check_visibility(
- self.ds, request.actor, "view-database", "database", name,
+ self.ds, request.actor, "view-database", name,
)
if not visible:
continue
@@ -36,7 +36,7 @@ class IndexView(BaseView):
views = []
for view_name in await db.view_names():
visible, private = await check_visibility(
- self.ds, request.actor, "view-table", "table", (name, view_name),
+ self.ds, request.actor, "view-table", (name, view_name),
)
if visible:
views.append({"name": view_name, "private": private})
@@ -52,7 +52,7 @@ class IndexView(BaseView):
tables = {}
for table in table_names:
visible, private = await check_visibility(
- self.ds, request.actor, "view-table", "table", (name, table),
+ self.ds, request.actor, "view-table", (name, table),
)
if not visible:
continue
diff --git a/datasette/views/table.py b/datasette/views/table.py
index cd952568..4cec0cda 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -268,11 +268,11 @@ class TableView(RowTableShared):
raise NotFound("Table not found: {}".format(table))
await self.check_permission(request, "view-instance")
- await self.check_permission(request, "view-database", "database", database)
- await self.check_permission(request, "view-table", "table", (database, table))
+ await self.check_permission(request, "view-database", database)
+ await self.check_permission(request, "view-table", (database, table))
private = not await self.ds.permission_allowed(
- None, "view-table", "table", (database, table), default=True
+ None, "view-table", (database, table), default=True
)
pks = await db.primary_keys(table)
@@ -854,8 +854,8 @@ class RowView(RowTableShared):
async def data(self, request, database, hash, table, pk_path, default_labels=False):
pk_values = urlsafe_components(pk_path)
await self.check_permission(request, "view-instance")
- await self.check_permission(request, "view-database", "database", database)
- await self.check_permission(request, "view-table", "table", (database, table))
+ await self.check_permission(request, "view-database", database)
+ await self.check_permission(request, "view-table", (database, table))
db = self.ds.databases[database]
pks = await db.primary_keys(table)
use_rowid = not pks
diff --git a/docs/authentication.rst b/docs/authentication.rst
index bda6a0b7..67112969 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -52,7 +52,7 @@ The URL on the first line includes a one-use token which can be used to sign in
Permissions
===========
-Datasette plugins can check if an actor has permission to perform an action using the :ref:`datasette.permission_allowed(...)` method. This method is also used by Datasette core code itself, which allows plugins to help make decisions on which actions are allowed by implementing the :ref:`permission_allowed(...) ` plugin hook.
+Datasette plugins can check if an actor has permission to perform an action using the :ref:`datasette.permission_allowed(...)` method. This method is also used by Datasette core code itself, which allows plugins to help make decisions on which actions are allowed by implementing the :ref:`plugin_permission_allowed` plugin hook.
.. _authentication_permissions_canned_queries:
@@ -159,7 +159,7 @@ This is designed to help administrators and plugin authors understand exactly ho
Permissions
===========
-This section lists all of the permission checks that are carried out by Datasette core, along with their ``resource_type`` and ``resource_identifier`` if those are passed.
+This section lists all of the permission checks that are carried out by Datasette core, along with the ``resource_identifier`` if it was passed.
.. _permissions_view_instance:
@@ -176,9 +176,6 @@ view-database
Actor is allowed to view a database page, e.g. https://latest.datasette.io/fixtures
-``resource_type`` - string
- "database"
-
``resource_identifier`` - string
The name of the database
@@ -189,9 +186,6 @@ view-database-download
Actor is allowed to download a database, e.g. https://latest.datasette.io/fixtures.db
-``resource_type`` - string
- "database"
-
``resource_identifier`` - string
The name of the database
@@ -202,9 +196,6 @@ view-table
Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.io/fixtures/complex_foreign_keys
-``resource_type`` - string
- "table" - even if this is actually a SQL view
-
``resource_identifier`` - tuple: (string, string)
The name of the database, then the name of the table
@@ -215,9 +206,6 @@ view-query
Actor is allowed to view a :ref:`canned query ` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size
-``resource_type`` - string
- "query"
-
``resource_identifier`` - string
The name of the canned query
@@ -228,9 +216,6 @@ execute-sql
Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures?sql=select+100
-``resource_type`` - string
- "database"
-
``resource_identifier`` - string
The name of the database
diff --git a/docs/internals.rst b/docs/internals.rst
index 7498f017..1d61b6cb 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -121,8 +121,8 @@ Renders a `Jinja template `__ usin
.. _datasette_permission_allowed:
-await .permission_allowed(actor, action, resource_type=None, resource_identifier=None, default=False)
------------------------------------------------------------------------------------------------------
+await .permission_allowed(actor, action, resource_identifier=None, default=False)
+---------------------------------------------------------------------------------
``actor`` - dictionary
The authenticated actor. This is usually ``request.actor``.
@@ -130,9 +130,6 @@ await .permission_allowed(actor, action, resource_type=None, resource_identifier
``action`` - string
The name of the action that is being permission checked.
-``resource_type`` - string, optional
- The type of resource being checked, e.g. ``"table"``.
-
``resource_identifier`` - string, optional
The resource identifier, e.g. the name of the table.
diff --git a/docs/plugins.rst b/docs/plugins.rst
index ecc7cbf1..118fab84 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1005,8 +1005,8 @@ Instead of returning a dictionary, this function can return an awaitable functio
.. _plugin_permission_allowed:
-permission_allowed(datasette, actor, action, resource_type, resource_identifier)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+permission_allowed(datasette, actor, action, resource_identifier)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
@@ -1017,10 +1017,7 @@ permission_allowed(datasette, actor, action, resource_type, resource_identifier)
``action`` - string
The action to be performed, e.g. ``"edit-table"``.
-``resource_type`` - string
- The type of resource being acted on, e.g. ``"table"``.
-
-``resource`` - string
+``resource_identifier`` - string
An identifier for the individual resource, e.g. the name of the table.
Called to check that an actor has permission to perform an action on a resource. Can return ``True`` if the action is allowed, ``False`` if the action is not allowed or ``None`` if the plugin does not have an opinion one way or the other.
diff --git a/tests/conftest.py b/tests/conftest.py
index 1921ae3a..7f1e9387 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -70,8 +70,8 @@ def check_permission_actions_are_documented():
action = kwargs.get("action").replace("-", "_")
assert (
action in documented_permission_actions
- ), "Undocumented permission action: {}, resource_type: {}, resource_identifier: {}".format(
- action, kwargs["resource_type"], kwargs["resource_identifier"]
+ ), "Undocumented permission action: {}, resource_identifier: {}".format(
+ action, kwargs["resource_identifier"]
)
pm.add_hookcall_monitoring(
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 2ac73fb1..8210d34f 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -857,24 +857,21 @@ if __name__ == "__main__":
def assert_permissions_checked(datasette, actions):
- # actions is a list of "action" or (action, resource_type, resource_identifier) tuples
+ # actions is a list of "action" or (action, resource_identifier) tuples
for action in actions:
if isinstance(action, str):
- resource_type = None
resource_identifier = None
else:
- action, resource_type, resource_identifier = action
+ action, resource_identifier = action
assert [
pc
for pc in datasette._permission_checks
if pc["action"] == action
- and pc["resource_type"] == resource_type
and pc["resource_identifier"] == resource_identifier
- ], """Missing expected permission check: action={}, resource_type={}, resource_identifier={}
+ ], """Missing expected permission check: action={}, resource_identifier={}
Permission checks seen: {}
""".format(
action,
- resource_type,
resource_identifier,
json.dumps(list(datasette._permission_checks), indent=4),
)
From 799c5d53570d773203527f19530cf772dc2eeb24 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 11:59:11 -0700
Subject: [PATCH 0083/1871] Renamed resource_identifier to resource, refs #817
---
datasette/app.py | 11 +++--------
datasette/default_permissions.py | 8 ++++----
datasette/hookspecs.py | 2 +-
datasette/templates/permissions_debug.html | 4 ++--
datasette/utils/__init__.py | 6 +++---
datasette/views/base.py | 7 ++-----
datasette/views/database.py | 2 +-
docs/authentication.rst | 12 ++++++------
docs/internals.rst | 10 ++++++----
docs/plugins.rst | 6 ++++--
tests/conftest.py | 4 ++--
tests/fixtures.py | 15 ++++++---------
12 files changed, 40 insertions(+), 47 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index c12e0af0..2f89d17c 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -464,16 +464,11 @@ class Datasette:
else:
return []
- async def permission_allowed(
- self, actor, action, resource_identifier=None, default=False
- ):
+ async def permission_allowed(self, actor, action, resource=None, default=False):
"Check permissions using the permissions_allowed plugin hook"
result = None
for check in pm.hook.permission_allowed(
- datasette=self,
- actor=actor,
- action=action,
- resource_identifier=resource_identifier,
+ datasette=self, actor=actor, action=action, resource=resource,
):
if callable(check):
check = check()
@@ -490,7 +485,7 @@ class Datasette:
"when": datetime.datetime.utcnow().isoformat(),
"actor": actor,
"action": action,
- "resource_identifier": resource_identifier,
+ "resource": resource,
"used_default": used_default,
"result": result,
}
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index d27704aa..e989c0fa 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -3,7 +3,7 @@ from datasette.utils import actor_matches_allow
@hookimpl
-def permission_allowed(datasette, actor, action, resource_identifier):
+def permission_allowed(datasette, actor, action, resource):
if action == "permissions-debug":
if actor and actor.get("id") == "root":
return True
@@ -12,12 +12,12 @@ def permission_allowed(datasette, actor, action, resource_identifier):
if allow is not None:
return actor_matches_allow(actor, allow)
elif action == "view-database":
- database_allow = datasette.metadata("allow", database=resource_identifier)
+ database_allow = datasette.metadata("allow", database=resource)
if database_allow is None:
return True
return actor_matches_allow(actor, database_allow)
elif action == "view-table":
- database, table = resource_identifier
+ database, table = resource
tables = datasette.metadata("tables", database=database) or {}
table_allow = (tables.get(table) or {}).get("allow")
if table_allow is None:
@@ -25,7 +25,7 @@ def permission_allowed(datasette, actor, action, resource_identifier):
return actor_matches_allow(actor, table_allow)
elif action == "view-query":
# Check if this query has a "allow" block in metadata
- database, query_name = resource_identifier
+ database, query_name = resource
queries_metadata = datasette.metadata("queries", database=database)
assert query_name in queries_metadata
if isinstance(queries_metadata[query_name], str):
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 3c202553..d5fd232f 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -66,5 +66,5 @@ def actor_from_request(datasette, request):
@hookspec
-def permission_allowed(datasette, actor, action, resource_identifier):
+def permission_allowed(datasette, actor, action, resource):
"Check if actor is allowed to perfom this action - return True, False or None"
diff --git a/datasette/templates/permissions_debug.html b/datasette/templates/permissions_debug.html
index 7d3ee712..d898ea8c 100644
--- a/datasette/templates/permissions_debug.html
+++ b/datasette/templates/permissions_debug.html
@@ -46,8 +46,8 @@
{% endif %}
Actor: {{ check.actor|tojson }}
- {% if check.resource_identifier %}
- Resource: {{ check.resource_identifier }}
+ {% if check.resource %}
+ Resource: {{ check.resource }}
{% endif %}
{% endfor %}
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 257d1285..7c1f34e0 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -876,14 +876,14 @@ def actor_matches_allow(actor, allow):
return False
-async def check_visibility(datasette, actor, action, resource_identifier, default=True):
+async def check_visibility(datasette, actor, action, resource, default=True):
"Returns (visible, private) - visible = can you see it, private = can others see it too"
visible = await datasette.permission_allowed(
- actor, action, resource_identifier=resource_identifier, default=default,
+ actor, action, resource=resource, default=default,
)
if not visible:
return (False, False)
private = not await datasette.permission_allowed(
- None, action, resource_identifier=resource_identifier, default=default,
+ None, action, resource=resource, default=default,
)
return visible, private
diff --git a/datasette/views/base.py b/datasette/views/base.py
index 2ca5e86a..f327c6cd 100644
--- a/datasette/views/base.py
+++ b/datasette/views/base.py
@@ -64,12 +64,9 @@ class BaseView(AsgiView):
response.body = b""
return response
- async def check_permission(self, request, action, resource_identifier=None):
+ async def check_permission(self, request, action, resource=None):
ok = await self.ds.permission_allowed(
- request.actor,
- action,
- resource_identifier=resource_identifier,
- default=True,
+ request.actor, action, resource=resource, default=True,
)
if not ok:
raise Forbidden(action)
diff --git a/datasette/views/database.py b/datasette/views/database.py
index d562ecb1..e1b29c27 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -88,7 +88,7 @@ class DatabaseView(DataView):
"views": views,
"queries": canned_queries,
"private": not await self.ds.permission_allowed(
- None, "view-database", "database", database
+ None, "view-database", database
),
},
{
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 67112969..f5209dfc 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -159,7 +159,7 @@ This is designed to help administrators and plugin authors understand exactly ho
Permissions
===========
-This section lists all of the permission checks that are carried out by Datasette core, along with the ``resource_identifier`` if it was passed.
+This section lists all of the permission checks that are carried out by Datasette core, along with the ``resource`` if it was passed.
.. _permissions_view_instance:
@@ -176,7 +176,7 @@ view-database
Actor is allowed to view a database page, e.g. https://latest.datasette.io/fixtures
-``resource_identifier`` - string
+``resource`` - string
The name of the database
.. _permissions_view_database_download:
@@ -186,7 +186,7 @@ view-database-download
Actor is allowed to download a database, e.g. https://latest.datasette.io/fixtures.db
-``resource_identifier`` - string
+``resource`` - string
The name of the database
.. _permissions_view_table:
@@ -196,7 +196,7 @@ view-table
Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.io/fixtures/complex_foreign_keys
-``resource_identifier`` - tuple: (string, string)
+``resource`` - tuple: (string, string)
The name of the database, then the name of the table
.. _permissions_view_query:
@@ -206,7 +206,7 @@ view-query
Actor is allowed to view a :ref:`canned query ` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size
-``resource_identifier`` - string
+``resource`` - string
The name of the canned query
.. _permissions_execute_sql:
@@ -216,7 +216,7 @@ execute-sql
Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures?sql=select+100
-``resource_identifier`` - string
+``resource`` - string
The name of the database
.. _permissions_permissions_debug:
diff --git a/docs/internals.rst b/docs/internals.rst
index 1d61b6cb..83dbd897 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -121,8 +121,8 @@ Renders a `Jinja template `__ usin
.. _datasette_permission_allowed:
-await .permission_allowed(actor, action, resource_identifier=None, default=False)
----------------------------------------------------------------------------------
+await .permission_allowed(actor, action, resource=None, default=False)
+----------------------------------------------------------------------
``actor`` - dictionary
The authenticated actor. This is usually ``request.actor``.
@@ -130,13 +130,15 @@ await .permission_allowed(actor, action, resource_identifier=None, default=False
``action`` - string
The name of the action that is being permission checked.
-``resource_identifier`` - string, optional
- The resource identifier, e.g. the name of the table.
+``resource`` - string, optional
+ The resource, e.g. the name of the table. Only some permissions apply to a resource.
Check if the given actor has permission to perform the given action on the given resource. This uses plugins that implement the :ref:`plugin_permission_allowed` plugin hook to decide if the action is allowed or not.
If none of the plugins express an opinion, the return value will be the ``default`` argument. This is deny, but you can pass ``default=True`` to default allow instead.
+See :ref:`permissions` for a full list of permissions included in Datasette core.
+
.. _datasette_get_database:
.get_database(name)
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 118fab84..56041d0c 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1005,7 +1005,7 @@ Instead of returning a dictionary, this function can return an awaitable functio
.. _plugin_permission_allowed:
-permission_allowed(datasette, actor, action, resource_identifier)
+permission_allowed(datasette, actor, action, resource)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``datasette`` - :ref:`internals_datasette`
@@ -1017,7 +1017,9 @@ permission_allowed(datasette, actor, action, resource_identifier)
``action`` - string
The action to be performed, e.g. ``"edit-table"``.
-``resource_identifier`` - string
+``resource`` - string or None
An identifier for the individual resource, e.g. the name of the table.
Called to check that an actor has permission to perform an action on a resource. Can return ``True`` if the action is allowed, ``False`` if the action is not allowed or ``None`` if the plugin does not have an opinion one way or the other.
+
+See :ref:`permissions` for a full list of permissions included in Datasette core.
diff --git a/tests/conftest.py b/tests/conftest.py
index 7f1e9387..320aa45b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -70,8 +70,8 @@ def check_permission_actions_are_documented():
action = kwargs.get("action").replace("-", "_")
assert (
action in documented_permission_actions
- ), "Undocumented permission action: {}, resource_identifier: {}".format(
- action, kwargs["resource_identifier"]
+ ), "Undocumented permission action: {}, resource: {}".format(
+ action, kwargs["resource"]
)
pm.add_hookcall_monitoring(
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 8210d34f..e9175b57 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -857,21 +857,18 @@ if __name__ == "__main__":
def assert_permissions_checked(datasette, actions):
- # actions is a list of "action" or (action, resource_identifier) tuples
+ # actions is a list of "action" or (action, resource) tuples
for action in actions:
if isinstance(action, str):
- resource_identifier = None
+ resource = None
else:
- action, resource_identifier = action
+ action, resource = action
assert [
pc
for pc in datasette._permission_checks
- if pc["action"] == action
- and pc["resource_identifier"] == resource_identifier
- ], """Missing expected permission check: action={}, resource_identifier={}
+ if pc["action"] == action and pc["resource"] == resource
+ ], """Missing expected permission check: action={}, resource={}
Permission checks seen: {}
""".format(
- action,
- resource_identifier,
- json.dumps(list(datasette._permission_checks), indent=4),
+ action, resource, json.dumps(list(datasette._permission_checks), indent=4),
)
From 040fc0546f1ad602125ecdc27d9d013d830aa808 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 12:02:56 -0700
Subject: [PATCH 0084/1871] Updated tests, refs #817
---
tests/test_permissions.py | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 475f93dd..90ba1494 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -210,41 +210,41 @@ def test_query_list_respects_view_query():
"path,permissions",
[
("/", ["view-instance"]),
- ("/fixtures", ["view-instance", ("view-database", "database", "fixtures")]),
+ ("/fixtures", ["view-instance", ("view-database", "fixtures")]),
(
"/fixtures/facetable/1",
- ["view-instance", ("view-table", "table", ("fixtures", "facetable"))],
+ ["view-instance", ("view-table", ("fixtures", "facetable"))],
),
(
"/fixtures/simple_primary_key",
[
"view-instance",
- ("view-database", "database", "fixtures"),
- ("view-table", "table", ("fixtures", "simple_primary_key")),
+ ("view-database", "fixtures"),
+ ("view-table", ("fixtures", "simple_primary_key")),
],
),
(
"/fixtures?sql=select+1",
[
"view-instance",
- ("view-database", "database", "fixtures"),
- ("execute-sql", "database", "fixtures"),
+ ("view-database", "fixtures"),
+ ("execute-sql", "fixtures"),
],
),
(
"/fixtures.db",
[
"view-instance",
- ("view-database", "database", "fixtures"),
- ("view-database-download", "database", "fixtures"),
+ ("view-database", "fixtures"),
+ ("view-database-download", "fixtures"),
],
),
(
"/fixtures/neighborhood_search",
[
"view-instance",
- ("view-database", "database", "fixtures"),
- ("view-query", "query", ("fixtures", "neighborhood_search")),
+ ("view-database", "fixtures"),
+ ("view-query", ("fixtures", "neighborhood_search")),
],
),
],
From c7d145e016522dd6ee229d4d0b3ba79a7a8877c1 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 12:06:05 -0700
Subject: [PATCH 0085/1871] Updated example for extra_template_vars hook,
closes #816
---
docs/plugins.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 56041d0c..6b1e60f2 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -689,14 +689,14 @@ Function that returns an awaitable function that returns a dictionary
Datasette runs Jinja2 in `async mode `__, which means you can add awaitable functions to the template scope and they will be automatically awaited when they are rendered by the template.
-Here's an example plugin that returns an authentication object from the ASGI scope:
+Here's an example plugin that adds a ``"user_agent"`` variable to the template context containing the current request's User-Agent header:
.. code-block:: python
@hookimpl
def extra_template_vars(request):
return {
- "auth": request.scope.get("auth")
+ "user_agent": request.headers.get("user-agent")
}
This example returns an awaitable function which adds a list of ``hidden_table_names`` to the context:
From 54370853828bdf87ca844fd0fc00900e0e2e659d Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 12:32:27 -0700
Subject: [PATCH 0086/1871] Documentation for allow blocks on more stuff,
closes #811
---
docs/authentication.rst | 121 ++++++++++++++++++++++++++++++++--------
docs/sql_queries.rst | 2 +-
2 files changed, 100 insertions(+), 23 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index f5209dfc..a6c4ee79 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -15,7 +15,7 @@ Actors
Through plugins, Datasette can support both authenticated users (with cookies) and authenticated API agents (via authentication tokens). The word "actor" is used to cover both of these cases.
-Every request to Datasette has an associated actor value. This can be ``None`` for unauthenticated requests, or a JSON compatible Python dictionary for authenticated users or API agents.
+Every request to Datasette has an associated actor value, available in the code as ``request.actor``. This can be ``None`` for unauthenticated requests, or a JSON compatible Python dictionary for authenticated users or API agents.
The only required field in an actor is ``"id"``, which must be a string. Plugins may decide to add any other fields to the actor dictionary.
@@ -24,7 +24,7 @@ Plugins can use the :ref:`plugin_actor_from_request` hook to implement custom lo
.. _authentication_root:
Using the "root" actor
-======================
+----------------------
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github `__ for example.
@@ -49,37 +49,40 @@ The URL on the first line includes a one-use token which can be used to sign in
.. _authentication_permissions:
-Permissions
-===========
+Checking permission
+===================
Datasette plugins can check if an actor has permission to perform an action using the :ref:`datasette.permission_allowed(...)` method. This method is also used by Datasette core code itself, which allows plugins to help make decisions on which actions are allowed by implementing the :ref:`plugin_permission_allowed` plugin hook.
-.. _authentication_permissions_canned_queries:
+.. _authentication_permissions_metadata:
-Permissions for canned queries
-==============================
+Configuring permissions in metadata.json
+========================================
-Datasette's :ref:`canned queries ` default to allowing any user to execute them.
+You can limit who is allowed to view different parts of your Datasette instance using ``"allow"`` keys in your :ref:`metadata` configuration.
-You can limit who is allowed to execute a specific query with the ``"allow"`` key in the :ref:`metadata` configuration for that query.
+You can control the following:
-Here's how to restrict access to a write query to just the "root" user:
+* Access to the entire Datasette instance
+* Access to specific databases
+* Access to specific tables and views
+* Access to specific :ref:`canned_queries`
+
+If a user cannot access a specific database, they will not be able to access tables, views or queries within that database. If a user cannot access the instance they will not be able to access any of the databases, tables, views or queries.
+
+.. _authentication_permissions_instance:
+
+Controlling access to an instance
+---------------------------------
+
+Here's how to restrict access to your entire Datasette instance to just the ``"id": "root"`` user:
.. code-block:: json
{
- "databases": {
- "mydatabase": {
- "queries": {
- "add_name": {
- "sql": "INSERT INTO names (name) VALUES (:name)",
- "write": true,
- "allow": {
- "id": ["root"]
- }
- }
- }
- }
+ "title": "My private Datasette instance",
+ "allow": {
+ "id": "root"
}
}
@@ -126,6 +129,80 @@ If you want to provide access to any actor with a value for a specific key, use
These keys act as an "or" mechanism. A actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block.
+.. _authentication_permissions_database:
+
+Controlling access to specific databases
+----------------------------------------
+
+To limit access to a specific ``private.db`` database to just authenticated users, use the ``"allow"`` block like this:
+
+.. code-block:: json
+
+ {
+ "databases": {
+ "private": {
+ "allow": {
+ "id": "*"
+ }
+ }
+ }
+ }
+
+.. _authentication_permissions_table:
+
+Controlling access to specific tables and views
+-----------------------------------------------
+
+To limit access to the ``users`` table in your ``bakery.db`` database:
+
+.. code-block:: json
+
+ {
+ "databases": {
+ "bakery": {
+ "tables": {
+ "users": {
+ "allow": {
+ "id": "*"
+ }
+ }
+ }
+ }
+ }
+ }
+
+This works for SQL views as well - you can treat them as if they are tables.
+
+.. warning::
+ Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries.
+
+ If you are restricting access to specific tables you should also use the ``"allow_sql"`` block to prevent users from accessing
+
+.. _authentication_permissions_table:
+
+Controlling access to specific canned queries
+---------------------------------------------
+
+To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`:
+
+.. code-block:: json
+
+ {
+ "databases": {
+ "dogs": {
+ "queries": {
+ "add_name": {
+ "sql": "INSERT INTO names (name) VALUES (:name)",
+ "write": true,
+ "allow": {
+ "id": ["root"]
+ }
+ }
+ }
+ }
+ }
+ }
+
.. _authentication_actor_matches_allow:
actor_matches_allow()
diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst
index 5df8bdb0..5295a2e0 100644
--- a/docs/sql_queries.rst
+++ b/docs/sql_queries.rst
@@ -217,7 +217,7 @@ Writable canned queries
Canned queries by default are read-only. You can use the ``"write": true`` key to indicate that a canned query can write to the database.
-See :ref:`authentication_permissions_canned_queries` for details on how to add permission checks to canned queries, using the ``"allow"`` key.
+See :ref:`authentication_permissions_metadata` for details on how to add permission checks to canned queries, using the ``"allow"`` key.
.. code-block:: json
From 8205d58316ced1d5ae589b29a5a1b5ecb6257ab0 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 13:10:40 -0700
Subject: [PATCH 0087/1871] Corrected documentation for resource in view-query
---
docs/authentication.rst | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index a6c4ee79..88808428 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -245,7 +245,6 @@ view-instance
Top level permission - Actor is allowed to view any pages within this instance, starting at https://latest.datasette.io/
-
.. _permissions_view_database:
view-database
@@ -283,8 +282,8 @@ view-query
Actor is allowed to view a :ref:`canned query ` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size
-``resource`` - string
- The name of the canned query
+``resource`` - tuple: (string, string)
+ The name of the database, then the name of the canned query
.. _permissions_execute_sql:
From e0a4664fbab5556454dac7f3c798253a34db2928 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 15:09:57 -0700
Subject: [PATCH 0088/1871] Better example plugin for permission_allowed
Also fixed it so default permission checks run after plugin permission checks, refs #818
---
datasette/default_permissions.py | 2 +-
docs/authentication.rst | 4 ++--
docs/plugins.rst | 40 ++++++++++++++++++++++++++++++--
3 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index e989c0fa..a2f4a315 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -2,7 +2,7 @@ from datasette import hookimpl
from datasette.utils import actor_matches_allow
-@hookimpl
+@hookimpl(tryfirst=True)
def permission_allowed(datasette, actor, action, resource):
if action == "permissions-debug":
if actor and actor.get("id") == "root":
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 88808428..34d46511 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -174,11 +174,11 @@ To limit access to the ``users`` table in your ``bakery.db`` database:
This works for SQL views as well - you can treat them as if they are tables.
.. warning::
- Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries.
+ Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries, `like this `__ for example.
If you are restricting access to specific tables you should also use the ``"allow_sql"`` block to prevent users from accessing
-.. _authentication_permissions_table:
+.. _authentication_permissions_query:
Controlling access to specific canned queries
---------------------------------------------
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 6b1e60f2..73d2eabd 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1006,7 +1006,7 @@ Instead of returning a dictionary, this function can return an awaitable functio
.. _plugin_permission_allowed:
permission_allowed(datasette, actor, action, resource)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
@@ -1022,4 +1022,40 @@ permission_allowed(datasette, actor, action, resource)
Called to check that an actor has permission to perform an action on a resource. Can return ``True`` if the action is allowed, ``False`` if the action is not allowed or ``None`` if the plugin does not have an opinion one way or the other.
-See :ref:`permissions` for a full list of permissions included in Datasette core.
+Here's an example plugin which randomly selects if a permission should be allowed or denied, except for ``view-instance`` which always uses the default permission scheme instead.
+
+.. code-block:: python
+
+ from datasette import hookimpl
+ import random
+
+ @hookimpl
+ def permission_allowed(action):
+ if action != "view-instance":
+ # Return True or False at random
+ return random.random() > 0.5
+ # Returning None falls back to default permissions
+
+This function can alternatively return an awaitable function which itself returns ``True``, ``False`` or ``None``. You can use this option if you need to execute additional database queries using ``await datasette.execute(...)``.
+
+Here's an example that allows users to view the ``admin_log`` table only if their actor ``id`` is present in the ``admin_users`` table. It aso disallows arbitrary SQL queries for the ``staff.db`` database for all users.
+
+.. code-block:: python
+
+ @hookimpl
+ def permission_allowed(datasette, actor, action, resource):
+ async def inner():
+ if action == "execute-sql" and resource == "staff":
+ return False
+ if action == "view-table" and resource == ("staff", "admin_log"):
+ if not actor:
+ return False
+ user_id = actor["id"]
+ return await datasette.get_database("staff").execute(
+ "select count(*) from admin_users where user_id = :user_id",
+ {"user_id": user_id},
+ )
+
+ return inner
+
+See :ref:`permissions` for a full list of permissions that are included in Datasette core.
From 49d6d2f7b0f6cb02e25022e1c9403811f1fa0a7c Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 17:05:44 -0700
Subject: [PATCH 0089/1871] allow_sql block to control execute-sql upermission
in metadata.json, closes #813
Also removed the --config allow_sql:0 mechanism in favour of the new allow_sql block.
---
datasette/app.py | 1 -
datasette/default_permissions.py | 8 ++++++++
datasette/templates/database.html | 2 +-
datasette/templates/query.html | 2 +-
datasette/templates/table.html | 2 +-
datasette/views/database.py | 8 ++++++--
datasette/views/table.py | 9 +++++++--
docs/authentication.rst | 33 ++++++++++++++++++++++++++++++-
docs/config.rst | 9 ---------
docs/json_api.rst | 2 +-
docs/pages.rst | 2 +-
docs/sql_queries.rst | 4 ++--
tests/test_api.py | 12 ++---------
tests/test_config_dir.py | 3 ---
tests/test_html.py | 10 +---------
tests/test_permissions.py | 29 +++++++++++++++++++++++++++
16 files changed, 92 insertions(+), 44 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 2f89d17c..a7c3c66a 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -110,7 +110,6 @@ CONFIG_OPTIONS = (
"Allow users to download the original SQLite database files",
),
ConfigOption("suggest_facets", True, "Calculate and display suggested facets"),
- ConfigOption("allow_sql", True, "Allow arbitrary SQL queries via ?sql= parameter"),
ConfigOption(
"default_cache_ttl",
5,
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index a2f4a315..e750acbf 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -34,3 +34,11 @@ def permission_allowed(datasette, actor, action, resource):
if allow is None:
return True
return actor_matches_allow(actor, allow)
+ elif action == "execute-sql":
+ # Use allow_sql block from database block, or from top-level
+ database_allow_sql = datasette.metadata("allow_sql", database=resource)
+ if database_allow_sql is None:
+ database_allow_sql = datasette.metadata("allow_sql")
+ if database_allow_sql is None:
+ return True
+ return actor_matches_allow(actor, database_allow_sql)
diff --git a/datasette/templates/database.html b/datasette/templates/database.html
index 100faee4..5ae51ef7 100644
--- a/datasette/templates/database.html
+++ b/datasette/templates/database.html
@@ -22,7 +22,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
-{% if config.allow_sql %}
+{% if allow_execute_sql %}
Custom SQL query
diff --git a/datasette/templates/query.html b/datasette/templates/query.html
index 7771b101..c65953fb 100644
--- a/datasette/templates/query.html
+++ b/datasette/templates/query.html
@@ -35,7 +35,7 @@
Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}
{% if not hide_sql %}
- {% if editable and config.allow_sql %}
+ {% if editable and allow_execute_sql %}
{% else %}
{% if query %}{{ query.sql }}{% endif %}
diff --git a/datasette/templates/table.html b/datasette/templates/table.html
index 1289e125..373fd576 100644
--- a/datasette/templates/table.html
+++ b/datasette/templates/table.html
@@ -109,7 +109,7 @@
{% endif %}
-{% if query.sql and config.allow_sql %}
+{% if query.sql and allow_execute_sql %}
{% endif %}
diff --git a/datasette/views/database.py b/datasette/views/database.py
index e1b29c27..ee99bc2d 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -26,8 +26,6 @@ class DatabaseView(DataView):
self.ds.update_with_inherited_metadata(metadata)
if request.args.get("sql"):
- if not self.ds.config("allow_sql"):
- raise DatasetteError("sql= is not allowed", status=400)
sql = request.args.get("sql")
validate_sql_select(sql)
return await QueryView(self.ds).data(
@@ -90,6 +88,9 @@ class DatabaseView(DataView):
"private": not await self.ds.permission_allowed(
None, "view-database", database
),
+ "allow_execute_sql": await self.ds.permission_allowed(
+ request.actor, "execute-sql", database, default=True
+ ),
},
{
"show_hidden": request.args.get("_show_hidden"),
@@ -289,6 +290,9 @@ class QueryView(DataView):
"columns": columns,
"query": {"sql": sql, "params": params},
"private": private,
+ "allow_execute_sql": await self.ds.permission_allowed(
+ request.actor, "execute-sql", database, default=True
+ ),
},
extra_template,
templates,
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 4cec0cda..91245293 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -342,8 +342,10 @@ class TableView(RowTableShared):
extra_wheres_for_ui = []
# Add _where= from querystring
if "_where" in request.args:
- if not self.ds.config("allow_sql"):
- raise DatasetteError("_where= is not allowed", status=400)
+ if not await self.ds.permission_allowed(
+ request.actor, "execute-sql", resource=database, default=True,
+ ):
+ raise DatasetteError("_where= is not allowed", status=403)
else:
where_clauses.extend(request.args.getlist("_where"))
extra_wheres_for_ui = [
@@ -839,6 +841,9 @@ class TableView(RowTableShared):
"next": next_value and str(next_value) or None,
"next_url": next_url,
"private": private,
+ "allow_execute_sql": await self.ds.permission_allowed(
+ request.actor, "execute-sql", database, default=True
+ ),
},
extra_template,
(
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 34d46511..f7281db4 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -176,7 +176,7 @@ This works for SQL views as well - you can treat them as if they are tables.
.. warning::
Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries, `like this `__ for example.
- If you are restricting access to specific tables you should also use the ``"allow_sql"`` block to prevent users from accessing
+ If you are restricting access to specific tables you should also use the ``"allow_sql"`` block to prevent users from bypassing the limit with their own SQL queries - see :ref:`authentication_permissions_execute_sql`.
.. _authentication_permissions_query:
@@ -203,6 +203,37 @@ To limit access to the ``add_name`` canned query in your ``dogs.db`` database to
}
}
+.. _authentication_permissions_execute_sql:
+
+Controlling the ability to execute arbitrary SQL
+------------------------------------------------
+
+The ``"allow_sql"`` block can be used to control who is allowed to execute arbitrary SQL queries, both using the form on the database page e.g. https://latest.datasette.io/fixtures or by appending a ``?_where=`` parameter to the table page as seen on https://latest.datasette.io/fixtures/facetable?_where=city_id=1.
+
+To enable just the :ref:`root user` to execute SQL for all databases in your instance, use the following:
+
+.. code-block:: json
+
+ {
+ "allow_sql": {
+ "id": "root"
+ }
+ }
+
+To limit this ability for just one specific database, use this:
+
+.. code-block:: json
+
+ {
+ "databases": {
+ "mydatabase": {
+ "allow_sql": {
+ "id": "root"
+ }
+ }
+ }
+ }
+
.. _authentication_actor_matches_allow:
actor_matches_allow()
diff --git a/docs/config.rst b/docs/config.rst
index da93e40a..56b38613 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -150,15 +150,6 @@ Should users be able to download the original SQLite database using a link on th
datasette mydatabase.db --config allow_download:off
-.. _config_allow_sql:
-
-allow_sql
-~~~~~~~~~
-
-Enable/disable the ability for users to run custom SQL directly against a database. To disable this feature, run::
-
- datasette mydatabase.db --config allow_sql:off
-
.. _config_default_cache_ttl:
default_cache_ttl
diff --git a/docs/json_api.rst b/docs/json_api.rst
index 7d37d425..af98eecd 100644
--- a/docs/json_api.rst
+++ b/docs/json_api.rst
@@ -291,7 +291,7 @@ Special table arguments
though this could potentially result in errors if the wrong syntax is used.
``?_where=SQL-fragment``
- If the :ref:`config_allow_sql` config option is enabled, this parameter
+ If the :ref:`permissions_execute_sql` permission is enabled, this parameter
can be used to pass one or more additional SQL fragments to be used in the
`WHERE` clause of the SQL used to query the table.
diff --git a/docs/pages.rst b/docs/pages.rst
index f220f94d..ce8f5d06 100644
--- a/docs/pages.rst
+++ b/docs/pages.rst
@@ -29,7 +29,7 @@ Database
========
Each database has a page listing the tables, views and canned queries
-available for that database. If the :ref:`config_allow_sql` config option is enabled (it's turned on by default) there will also be an interface for executing arbitrary SQL select queries against the data.
+available for that database. If the :ref:`permissions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data.
Examples:
diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst
index 5295a2e0..db72deb7 100644
--- a/docs/sql_queries.rst
+++ b/docs/sql_queries.rst
@@ -12,8 +12,8 @@ you like. You can also construct queries using the filter interface on the
tables page, then click "View and edit SQL" to open that query in the custom
SQL editor.
-Note that this interface is only available if the :ref:`config_allow_sql` option
-has not been disabled.
+Note that this interface is only available if the :ref:`permissions_execute_sql`
+permission is allowed.
Any Datasette SQL query is reflected in the URL of the page, allowing you to
bookmark them, share them with others and navigate through previous queries
diff --git a/tests/test_api.py b/tests/test_api.py
index 13a98b6a..1a54edec 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -634,13 +634,6 @@ def test_invalid_custom_sql(app_client):
assert "Statement must be a SELECT" == response.json["error"]
-def test_allow_sql_off():
- with make_app_client(config={"allow_sql": False}) as client:
- response = client.get("/fixtures.json?sql=select+sleep(0.01)")
- assert 400 == response.status
- assert "sql= is not allowed" == response.json["error"]
-
-
def test_table_json(app_client):
response = app_client.get("/fixtures/simple_primary_key.json?_shape=objects")
assert response.status == 200
@@ -1137,9 +1130,9 @@ def test_table_filter_extra_where_invalid(app_client):
def test_table_filter_extra_where_disabled_if_no_sql_allowed():
- with make_app_client(config={"allow_sql": False}) as client:
+ with make_app_client(metadata={"allow_sql": {}}) as client:
response = client.get("/fixtures/facetable.json?_where=neighborhood='Dogpatch'")
- assert 400 == response.status
+ assert 403 == response.status
assert "_where= is not allowed" == response.json["error"]
@@ -1325,7 +1318,6 @@ def test_config_json(app_client):
"allow_download": True,
"allow_facet": True,
"suggest_facets": True,
- "allow_sql": True,
"default_cache_ttl": 5,
"default_cache_ttl_hashed": 365 * 24 * 60 * 60,
"num_sql_threads": 3,
diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py
index 490b1f1d..b1f6994f 100644
--- a/tests/test_config_dir.py
+++ b/tests/test_config_dir.py
@@ -10,7 +10,6 @@ from datasette import hookimpl
@hookimpl
def extra_template_vars():
- print("this is template vars")
return {
"from_plugin": "hooray"
}
@@ -18,7 +17,6 @@ def extra_template_vars():
METADATA = {"title": "This is from metadata"}
CONFIG = {
"default_cache_ttl": 60,
- "allow_sql": False,
}
CSS = """
body { margin-top: 3em}
@@ -91,7 +89,6 @@ def test_config(config_dir_client):
response = config_dir_client.get("/-/config.json")
assert 200 == response.status
assert 60 == response.json["default_cache_ttl"]
- assert not response.json["allow_sql"]
def test_plugins(config_dir_client):
diff --git a/tests/test_html.py b/tests/test_html.py
index cb0e0c90..e6933dfe 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -924,16 +924,8 @@ def test_allow_download_off():
assert 403 == response.status
-def test_allow_sql_on(app_client):
- response = app_client.get("/fixtures")
- soup = Soup(response.body, "html.parser")
- assert len(soup.findAll("textarea", {"name": "sql"}))
- response = app_client.get("/fixtures/sortable")
- assert b"View and edit SQL" in response.body
-
-
def test_allow_sql_off():
- with make_app_client(config={"allow_sql": False}) as client:
+ with make_app_client(metadata={"allow_sql": {}}) as client:
response = client.get("/fixtures")
soup = Soup(response.body, "html.parser")
assert not len(soup.findAll("textarea", {"name": "sql"}))
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 90ba1494..d8c98825 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -186,6 +186,35 @@ def test_view_query(allow, expected_anon, expected_auth):
assert ">fixtures 🔒" in auth_response.text
+@pytest.mark.parametrize(
+ "metadata",
+ [
+ {"allow_sql": {"id": "root"}},
+ {"databases": {"fixtures": {"allow_sql": {"id": "root"}}}},
+ ],
+)
+def test_execute_sql(metadata):
+ with make_app_client(metadata=metadata) as client:
+ form_fragment = '
Date: Mon, 8 Jun 2020 17:35:23 -0700
Subject: [PATCH 0090/1871] Fixed broken CSS on 404 page, closes #777
---
datasette/app.py | 11 ++++++++++-
tests/test_html.py | 12 ++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/datasette/app.py b/datasette/app.py
index a7c3c66a..d562e611 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -1015,7 +1015,16 @@ class DatasetteRouter(AsgiRouter):
templates = ["500.html"]
if status != 500:
templates = ["{}.html".format(status)] + templates
- info.update({"ok": False, "error": message, "status": status, "title": title})
+ info.update(
+ {
+ "ok": False,
+ "error": message,
+ "status": status,
+ "title": title,
+ "base_url": self.ds.config("base_url"),
+ "app_css_hash": self.ds.app_css_hash(),
+ }
+ )
headers = {}
if self.ds.cors:
headers["Access-Control-Allow-Origin"] = "*"
diff --git a/tests/test_html.py b/tests/test_html.py
index e6933dfe..f9b18daa 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -965,6 +965,18 @@ def inner_html(soup):
return inner_html.strip()
+@pytest.mark.parametrize("path", ["/404", "/fixtures/404"])
+def test_404(app_client, path):
+ response = app_client.get(path)
+ assert 404 == response.status
+ assert (
+ '
Date: Mon, 8 Jun 2020 19:22:40 -0700
Subject: [PATCH 0091/1871] Fixed test_table_not_exists_json test
---
datasette/app.py | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index d562e611..79f52a54 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -1016,14 +1016,7 @@ class DatasetteRouter(AsgiRouter):
if status != 500:
templates = ["{}.html".format(status)] + templates
info.update(
- {
- "ok": False,
- "error": message,
- "status": status,
- "title": title,
- "base_url": self.ds.config("base_url"),
- "app_css_hash": self.ds.app_css_hash(),
- }
+ {"ok": False, "error": message, "status": status, "title": title,}
)
headers = {}
if self.ds.cors:
@@ -1033,7 +1026,16 @@ class DatasetteRouter(AsgiRouter):
else:
template = self.ds.jinja_env.select_template(templates)
await asgi_send_html(
- send, await template.render_async(info), status=status, headers=headers
+ send,
+ await template.render_async(
+ dict(
+ info,
+ base_url=self.ds.config("base_url"),
+ app_css_hash=self.ds.app_css_hash(),
+ )
+ ),
+ status=status,
+ headers=headers,
)
From f5e79adf26d0daa3831e3fba022f1b749a9efdee Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 20:12:06 -0700
Subject: [PATCH 0092/1871] register_routes() plugin hook (#819)
Fixes #215
---
datasette/app.py | 21 ++++++++++++++++
datasette/hookspecs.py | 5 ++++
datasette/utils/__init__.py | 12 ++++++++-
datasette/utils/asgi.py | 2 +-
docs/index.rst | 2 +-
docs/plugins.rst | 50 ++++++++++++++++++++++++++++++++++++-
tests/fixtures.py | 1 +
tests/plugins/my_plugin.py | 25 +++++++++++++++++++
tests/test_plugins.py | 15 +++++++++++
9 files changed, 129 insertions(+), 4 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 79f52a54..120091f7 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -39,6 +39,7 @@ from .renderer import json_renderer
from .database import Database, QueryInterrupted
from .utils import (
+ async_call_with_supported_arguments,
escape_css_string,
escape_sqlite,
format_bytes,
@@ -783,6 +784,10 @@ class Datasette:
"Returns an ASGI app function that serves the whole of Datasette"
routes = []
+ for routes_to_add in pm.hook.register_routes():
+ for regex, view_fn in routes_to_add:
+ routes.append((regex, wrap_view(view_fn, self)))
+
def add_route(view, regex):
routes.append((regex, view))
@@ -1048,3 +1053,19 @@ def _cleaner_task_str(task):
# running at /Users/simonw/Dropbox/Development/datasette/venv-3.7.5/lib/python3.7/site-packages/uvicorn/main.py:361>
# Clean up everything up to and including site-packages
return _cleaner_task_str_re.sub("", s)
+
+
+def wrap_view(view_fn, datasette):
+ async def asgi_view_fn(scope, receive, send):
+ response = await async_call_with_supported_arguments(
+ view_fn,
+ scope=scope,
+ receive=receive,
+ send=send,
+ request=Request(scope, receive),
+ datasette=datasette,
+ )
+ if response is not None:
+ await response.asgi_send(send)
+
+ return asgi_view_fn
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index d5fd232f..ab3e131c 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -60,6 +60,11 @@ def register_facet_classes():
"Register Facet subclasses"
+@hookspec
+def register_routes():
+ "Register URL routes: return a list of (regex, view_function) pairs"
+
+
@hookspec
def actor_from_request(datasette, request):
"Return an actor dictionary based on the incoming request"
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 7c1f34e0..49268638 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -842,7 +842,7 @@ def parse_metadata(content):
raise BadMetadataError("Metadata is not valid JSON or YAML")
-def call_with_supported_arguments(fn, **kwargs):
+def _gather_arguments(fn, kwargs):
parameters = inspect.signature(fn).parameters.keys()
call_with = []
for parameter in parameters:
@@ -853,9 +853,19 @@ def call_with_supported_arguments(fn, **kwargs):
)
)
call_with.append(kwargs[parameter])
+ return call_with
+
+
+def call_with_supported_arguments(fn, **kwargs):
+ call_with = _gather_arguments(fn, kwargs)
return fn(*call_with)
+async def async_call_with_supported_arguments(fn, **kwargs):
+ call_with = _gather_arguments(fn, kwargs)
+ return await fn(*call_with)
+
+
def actor_matches_allow(actor, allow):
actor = actor or {}
if allow is None:
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index bca9c9ab..349f2a0a 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -399,7 +399,7 @@ class Response:
@classmethod
def text(cls, body, status=200, headers=None):
return cls(
- body,
+ str(body),
status=status,
headers=headers,
content_type="text/plain; charset=utf-8",
diff --git a/docs/index.rst b/docs/index.rst
index 03988c8e..5334386f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -31,7 +31,7 @@ Contents
--------
.. toctree::
- :maxdepth: 2
+ :maxdepth: 3
getting_started
installation
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 73d2eabd..caca0019 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -835,6 +835,55 @@ And here is an example ``can_render`` function which returns ``True`` only if th
Examples: `datasette-atom `_, `datasette-ics `_
+.. _plugin_register_routes:
+
+register_routes()
+~~~~~~~~~~~~~~~~~
+
+Register additional view functions to execute for specified URL routes.
+
+Return a list of ``(regex, async_view_function)`` pairs, something like this:
+
+.. code-block:: python
+
+ from datasette.utils.asgi import Response
+ import html
+
+
+ async def hello_from(scope):
+ name = scope["url_route"]["kwargs"]["name"]
+ return Response.html("Hello from {}".format(
+ html.escape(name)
+ ))
+
+
+ @hookimpl
+ def register_routes():
+ return [
+ (r"^/hello-from/(?P.*)$"), hello_from)
+ ]
+
+The view functions can take a number of different optional arguments. The corresponding argument will be passed to your function depending on its named parameters - a form of dependency injection.
+
+The optional view function arguments are as follows:
+
+``datasette`` - :ref:`internals_datasette`
+ You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
+
+``request`` - Request object
+ The current HTTP :ref:`internals_request`.
+
+``scope`` - dictionary
+ The incoming ASGI scope dictionary.
+
+``send`` - function
+ The ASGI send function.
+
+``receive`` - function
+ The ASGI receive function.
+
+The function can either return a ``Response`` or it can return nothing and instead respond directly to the request using the ASGI ``receive`` function (for advanced uses only).
+
.. _plugin_register_facet_classes:
register_facet_classes()
@@ -901,7 +950,6 @@ The plugin hook can then be used to register the new facet class like this:
def register_facet_classes():
return [SpecialFacet]
-
.. _plugin_asgi_wrapper:
asgi_wrapper(datasette)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index e9175b57..a51a869d 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -46,6 +46,7 @@ EXPECTED_PLUGINS = [
"prepare_connection",
"prepare_jinja2_environment",
"register_facet_classes",
+ "register_routes",
"render_cell",
],
},
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index 46893710..57803178 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -1,6 +1,7 @@
from datasette import hookimpl
from datasette.facets import Facet
from datasette.utils import path_with_added_args
+from datasette.utils.asgi import asgi_send_json, Response
import base64
import pint
import json
@@ -142,3 +143,27 @@ def permission_allowed(actor, action):
return True
elif action == "this_is_denied":
return False
+
+
+@hookimpl
+def register_routes():
+ async def one(datasette):
+ return Response.text(
+ (await datasette.get_database().execute("select 1 + 1")).first()[0]
+ )
+
+ async def two(request, scope):
+ name = scope["url_route"]["kwargs"]["name"]
+ greeting = request.args.get("greeting")
+ return Response.text("{} {}".format(greeting, name))
+
+ async def three(scope, send):
+ await asgi_send_json(
+ send, {"hello": "world"}, status=200, headers={"x-three": "1"}
+ )
+
+ return [
+ (r"/one/$", one),
+ (r"/two/(?P.*)$", two),
+ (r"/three/$", three),
+ ]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index c782b87b..c7bb4859 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -544,3 +544,18 @@ def test_actor_json(app_client):
assert {"actor": {"id": "bot2", "1+1": 2}} == app_client.get(
"/-/actor.json/?_bot2=1"
).json
+
+
+@pytest.mark.parametrize(
+ "path,body", [("/one/", "2"), ("/two/Ray?greeting=Hail", "Hail Ray"),]
+)
+def test_register_routes(app_client, path, body):
+ response = app_client.get(path)
+ assert 200 == response.status
+ assert body == response.text
+
+
+def test_register_routes_asgi(app_client):
+ response = app_client.get("/three/")
+ assert {"hello": "world"} == response.json
+ assert "1" == response.headers["x-three"]
From db660db4632409334e646237c3dd214764729cd4 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 20:32:10 -0700
Subject: [PATCH 0093/1871] Docs + unit tests for Response, closes #821
---
datasette/utils/asgi.py | 9 ++++++
docs/internals.rst | 48 ++++++++++++++++++++++++++++++++
docs/plugins.rst | 2 +-
tests/test_internals_response.py | 28 +++++++++++++++++++
4 files changed, 86 insertions(+), 1 deletion(-)
create mode 100644 tests/test_internals_response.py
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index 349f2a0a..9e6c82dd 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -405,6 +405,15 @@ class Response:
content_type="text/plain; charset=utf-8",
)
+ @classmethod
+ def json(cls, body, status=200, headers=None):
+ return cls(
+ json.dumps(body),
+ status=status,
+ headers=headers,
+ content_type="application/json; charset=utf-8",
+ )
+
@classmethod
def redirect(cls, path, status=302, headers=None):
headers = headers or {}
diff --git a/docs/internals.rst b/docs/internals.rst
index 83dbd897..b0096cfa 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -80,6 +80,54 @@ Consider the querystring ``?foo=1&foo=2&bar=3`` - with two values for ``foo`` an
``len(request.args)`` - integer
Returns the number of keys.
+.. _internals_response:
+
+Response class
+~~~~~~~~~~~~~~
+
+The ``Response`` class can be returned from view functions that have been registered using the :ref:`plugin_register_routes` hook.
+
+The ``Response()`` constructor takes the following arguments:
+
+``body`` - string
+ The body of the response.
+
+``status`` - integer (optional)
+ The HTTP status - defaults to 200.
+
+``headers`` - dictionary (optional)
+ A dictionary of extra HTTP headers, e.g. ``{"x-hello": "world"}``.
+
+``content_type`` - string (optional)
+ The content-type for the response. Defaults to ``text/plain``.
+
+For example:
+
+.. code-block:: python
+
+ from datasette.utils.asgi import Response
+
+ response = Response(
+ "This is XML ",
+ content_type="application/xml; charset=utf-8"
+ )
+
+The easiest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)`` or ``Response.redirect(...)`` helper methods:
+
+.. code-block:: python
+
+ from datasette.utils.asgi import Response
+
+ html_response = Response.html("This is HTML")
+ json_response = Response.json({"this_is": "json"})
+ text_response = Response.text("This will become utf-8 encoded text")
+ # Redirects are served as 302, unless you pass status=301:
+ redirect_response = Response.redirect("https://latest.datasette.io/")
+
+Each of these responses will use the correct corresponding content-type - ``text/html; charset=utf-8``, ``application/json; charset=utf-8`` or ``text/plain; charset=utf-8`` respectively.
+
+Each of the helper methods take optional ``status=`` and ``headers=`` arguments, documented above.
+
.. _internals_datasette:
Datasette class
diff --git a/docs/plugins.rst b/docs/plugins.rst
index caca0019..465fcd52 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -882,7 +882,7 @@ The optional view function arguments are as follows:
``receive`` - function
The ASGI receive function.
-The function can either return a ``Response`` or it can return nothing and instead respond directly to the request using the ASGI ``receive`` function (for advanced uses only).
+The function can either return a :ref:`internals_response` or it can return nothing and instead respond directly to the request using the ASGI ``send`` function (for advanced uses only).
.. _plugin_register_facet_classes:
diff --git a/tests/test_internals_response.py b/tests/test_internals_response.py
new file mode 100644
index 00000000..7c11f858
--- /dev/null
+++ b/tests/test_internals_response.py
@@ -0,0 +1,28 @@
+from datasette.utils.asgi import Response
+
+
+def test_response_html():
+ response = Response.html("Hello from HTML")
+ assert 200 == response.status
+ assert "Hello from HTML" == response.body
+ assert "text/html; charset=utf-8" == response.content_type
+
+
+def test_response_text():
+ response = Response.text("Hello from text")
+ assert 200 == response.status
+ assert "Hello from text" == response.body
+ assert "text/plain; charset=utf-8" == response.content_type
+
+
+def test_response_json():
+ response = Response.json({"this_is": "json"})
+ assert 200 == response.status
+ assert '{"this_is": "json"}' == response.body
+ assert "application/json; charset=utf-8" == response.content_type
+
+
+def test_response_redirect():
+ response = Response.redirect("/foo")
+ assert 302 == response.status
+ assert "/foo" == response.headers["Location"]
From fac8e9381500fc02cec99281122ee8e0c72fabe1 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 20:40:00 -0700
Subject: [PATCH 0094/1871] request.url_vars property, closes #822
---
datasette/utils/asgi.py | 4 ++++
docs/internals.rst | 3 +++
docs/plugins.rst | 4 ++--
tests/plugins/my_plugin.py | 4 ++--
tests/test_internals_request.py | 17 +++++++++++++++++
5 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index 9e6c82dd..cdd6b148 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -32,6 +32,10 @@ class Request:
(self.scheme, self.host, self.path, None, self.query_string, None)
)
+ @property
+ def url_vars(self):
+ return (self.scope.get("url_route") or {}).get("kwargs") or {}
+
@property
def scheme(self):
return self.scope.get("scheme") or "http"
diff --git a/docs/internals.rst b/docs/internals.rst
index b0096cfa..df21eb09 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -42,6 +42,9 @@ The request object is passed to various plugin hooks. It represents an incoming
``.args`` - MultiParams
An object representing the parsed querystring parameters, see below.
+``.url_vars`` - dictionary (str -> str)
+ Variables extracted from the URL path, if that path was defined using a regular expression. See :ref:`plugin_register_routes`.
+
``.actor`` - dictionary (str -> Any) or None
The currently authenticated actor (see :ref:`actors `), or ``None`` if the request is unauthenticated.
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 465fcd52..17fd64df 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -850,8 +850,8 @@ Return a list of ``(regex, async_view_function)`` pairs, something like this:
import html
- async def hello_from(scope):
- name = scope["url_route"]["kwargs"]["name"]
+ async def hello_from(request):
+ name = request.url_vars["name"]
return Response.html("Hello from {}".format(
html.escape(name)
))
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index 57803178..a0f7441b 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -152,8 +152,8 @@ def register_routes():
(await datasette.get_database().execute("select 1 + 1")).first()[0]
)
- async def two(request, scope):
- name = scope["url_route"]["kwargs"]["name"]
+ async def two(request):
+ name = request.url_vars["name"]
greeting = request.args.get("greeting")
return Response.text("{} {}".format(greeting, name))
diff --git a/tests/test_internals_request.py b/tests/test_internals_request.py
index 433b23d5..8367a693 100644
--- a/tests/test_internals_request.py
+++ b/tests/test_internals_request.py
@@ -44,3 +44,20 @@ def test_request_args():
assert 2 == len(request.args)
with pytest.raises(KeyError):
request.args["missing"]
+
+
+def test_request_url_vars():
+ scope = {
+ "http_version": "1.1",
+ "method": "POST",
+ "path": "/",
+ "raw_path": b"/",
+ "query_string": b"",
+ "scheme": "http",
+ "type": "http",
+ "headers": [[b"content-type", b"application/x-www-form-urlencoded"]],
+ }
+ assert {} == Request(scope, None).url_vars
+ assert {"name": "cleo"} == Request(
+ dict(scope, url_route={"kwargs": {"name": "cleo"}}), None
+ ).url_vars
From 5a6a73e3190cac103906b479d56129413e5ef190 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 21:37:35 -0700
Subject: [PATCH 0095/1871] Replace os.urandom(32).hex() with
secrets.token_hex(32)
---
datasette/app.py | 5 +++--
docs/config.rst | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 120091f7..633ca4fe 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -8,6 +8,7 @@ import itertools
import json
import os
import re
+import secrets
import sys
import threading
import traceback
@@ -186,7 +187,7 @@ class Datasette:
assert config_dir is None or isinstance(
config_dir, Path
), "config_dir= should be a pathlib.Path"
- self._secret = secret or os.urandom(32).hex()
+ self._secret = secret or secrets.token_hex(32)
self.files = tuple(files) + tuple(immutables or [])
if config_dir:
self.files += tuple([str(p) for p in config_dir.glob("*.db")])
@@ -299,7 +300,7 @@ class Datasette:
self._register_renderers()
self._permission_checks = collections.deque(maxlen=200)
- self._root_token = os.urandom(32).hex()
+ self._root_token = secrets.token_hex(32)
def sign(self, value, namespace="default"):
return URLSafeSerializer(self._secret, namespace).dumps(value)
diff --git a/docs/config.rst b/docs/config.rst
index 56b38613..ab14ea7b 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -302,7 +302,7 @@ Or::
One way to generate a secure random secret is to use Python like this::
- $ python3 -c 'import os; print(os.urandom(32).hex())'
+ $ python3 -c 'import secrets; print(secrets.token_hex(32))'
cdb19e94283a20f9d42cca50c5a4871c0aa07392db308755d60a1a5b9bb0fa52
Plugin authors make use of this signing mechanism in their plugins using :ref:`datasette_sign` and :ref:`datasette_unsign`.
From eb3ec279becd3b81e5fa509244711548c86f434f Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Mon, 8 Jun 2020 23:33:06 -0700
Subject: [PATCH 0096/1871] Test for anonymous: true, refs #825
---
tests/test_utils.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 975ed0fd..4bade18b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -466,6 +466,7 @@ def test_multi_params(data, should_raise):
[
({"id": "root"}, None, True),
({"id": "root"}, {}, False),
+ ({"anonymous": True}, {"anonymous": True}, True),
(None, None, True),
(None, {}, False),
(None, {"id": "root"}, False),
From fec750435d405ac06cb61a5ddeda7317ef24843a Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 07:01:23 -0700
Subject: [PATCH 0097/1871] Support anonymous: true in actor_matches_allow,
refs #825
---
datasette/utils/__init__.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 49268638..d8cde95a 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -873,12 +873,12 @@ def actor_matches_allow(actor, allow):
for key, values in allow.items():
if values == "*" and key in actor:
return True
- if isinstance(values, str):
+ if not isinstance(values, list):
values = [values]
actor_values = actor.get(key)
if actor_values is None:
return False
- if isinstance(actor_values, str):
+ if not isinstance(actor_values, list):
actor_values = [actor_values]
actor_values = set(actor_values)
if actor_values.intersection(values):
From eefeafaa27a16af3bcb3150b4fe1ef6ee8d5c19f Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 07:09:39 -0700
Subject: [PATCH 0098/1871] Removed unused import
---
datasette/views/database.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/datasette/views/database.py b/datasette/views/database.py
index ee99bc2d..4fab2cfb 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -2,7 +2,6 @@ import os
import jinja2
from datasette.utils import (
- actor_matches_allow,
check_visibility,
to_css_class,
validate_sql_select,
From fa87d16612ff671683f35ecc5f5e36af007599e4 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 07:10:46 -0700
Subject: [PATCH 0099/1871] Clearer docs for actor_matches_allow
---
datasette/utils/__init__.py | 3 ++-
docs/authentication.rst | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index d8cde95a..5873fcaa 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -867,7 +867,8 @@ async def async_call_with_supported_arguments(fn, **kwargs):
def actor_matches_allow(actor, allow):
- actor = actor or {}
+ if actor is None:
+ actor = {"anonymous": True}
if allow is None:
return True
for key, values in allow.items():
diff --git a/docs/authentication.rst b/docs/authentication.rst
index f7281db4..04564886 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -239,7 +239,7 @@ To limit this ability for just one specific database, use this:
actor_matches_allow()
=====================
-Plugins that wish to implement the same permissions scheme as canned queries can take advantage of the ``datasette.utils.actor_matches_allow(actor, allow)`` function:
+Plugins that wish to implement this same ``"allow"`` block permissions scheme can take advantage of the ``datasette.utils.actor_matches_allow(actor, allow)`` function:
.. code-block:: python
From 3aa87eeaf21083e32d9e02bd857fd44707dc4113 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 07:58:12 -0700
Subject: [PATCH 0100/1871] Documentation no loger suggests that actor["id"] is
required, closes #823
---
docs/authentication.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 04564886..153466ad 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -17,7 +17,7 @@ Through plugins, Datasette can support both authenticated users (with cookies) a
Every request to Datasette has an associated actor value, available in the code as ``request.actor``. This can be ``None`` for unauthenticated requests, or a JSON compatible Python dictionary for authenticated users or API agents.
-The only required field in an actor is ``"id"``, which must be a string. Plugins may decide to add any other fields to the actor dictionary.
+The actor dictionary can be any shape - the design of that data structure is left up to the plugins. A useful convention is to include an ``"id"`` string, as demonstrated by the "root" actor below.
Plugins can use the :ref:`plugin_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request.
From 70dd14876e305ddb15263ec0687e23bef5b1ab78 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 09:04:46 -0700
Subject: [PATCH 0101/1871] Improved documentation for permissions, refs #699
---
docs/authentication.rst | 37 +++++++++++++++++++++++++++++--------
docs/sql_queries.rst | 6 ++++++
2 files changed, 35 insertions(+), 8 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 153466ad..e26c8fc5 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -4,7 +4,7 @@
Authentication and permissions
================================
-Datasette does not require authentication by default. Any visitor to a Datasette instance can explore the full data and execute SQL queries.
+Datasette does not require authentication by default. Any visitor to a Datasette instance can explore the full data and execute read-only SQL queries.
Datasette's plugin system can be used to add many different styles of authentication, such as user accounts, single sign-on or API keys.
@@ -49,10 +49,20 @@ The URL on the first line includes a one-use token which can be used to sign in
.. _authentication_permissions:
-Checking permission
-===================
+Permissions
+===========
-Datasette plugins can check if an actor has permission to perform an action using the :ref:`datasette.permission_allowed(...)` method. This method is also used by Datasette core code itself, which allows plugins to help make decisions on which actions are allowed by implementing the :ref:`plugin_permission_allowed` plugin hook.
+Datasette has an extensive permissions system built-in, which can be further extended and customized by plugins.
+
+The key question the permissions system answers is this:
+
+ Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
+
+**Actors** are :ref:`described above `.
+
+An **action** is a string describing the action the actor would like to perfom. A full list is :ref:`provided below ` - examples include ``view-table`` and ``execute-sql``.
+
+A **resource** is the item the actor wishes to interact with - for example a specific database or table. Some actions, such as ``permissions-debug``, are not associated with a particular resource.
.. _authentication_permissions_metadata:
@@ -115,7 +125,7 @@ You can provide access to any user that has "developer" as one of their roles li
}
}
-Note that "roles" is not a concept that is baked into Datasette - it's more of a convention that plugins can choose to implement and act on.
+Note that "roles" is not a concept that is baked into Datasette - it's a convention that plugins can choose to implement and act on.
If you want to provide access to any actor with a value for a specific key, use ``"*"``. For example, to spceify that a query can be accessed by any logged-in user use this:
@@ -171,7 +181,7 @@ To limit access to the ``users`` table in your ``bakery.db`` database:
}
}
-This works for SQL views as well - you can treat them as if they are tables.
+This works for SQL views as well - you can list their names in the ``"tables"`` block above in the same way as regular tables.
.. warning::
Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries, `like this `__ for example.
@@ -183,6 +193,8 @@ This works for SQL views as well - you can treat them as if they are tables.
Controlling access to specific canned queries
---------------------------------------------
+:ref:`canned_queries` allow you to configure named SQL queries in your ``metadata.json`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
+
To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`:
.. code-block:: json
@@ -234,6 +246,15 @@ To limit this ability for just one specific database, use this:
}
}
+.. _permissions_plugins:
+
+Checking permissions in plugins
+===============================
+
+Datasette plugins can check if an actor has permission to perform an action using the :ref:`datasette.permission_allowed(...)` method.
+
+Datasette core performs a number of permission checks, :ref:`documented below `. Plugins can implement the :ref:`plugin_permission_allowed` plugin hook to participate in decisions about whether an actor should be able to perform a specified action.
+
.. _authentication_actor_matches_allow:
actor_matches_allow()
@@ -264,8 +285,8 @@ This is designed to help administrators and plugin authors understand exactly ho
.. _permissions:
-Permissions
-===========
+Built-in permissions
+====================
This section lists all of the permission checks that are carried out by Datasette core, along with the ``resource`` if it was passed.
diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst
index db72deb7..a73f6bc2 100644
--- a/docs/sql_queries.rst
+++ b/docs/sql_queries.rst
@@ -1,3 +1,5 @@
+.. _sql:
+
Running SQL queries
===================
@@ -22,6 +24,8 @@ using your browser back button.
You can also retrieve the results of any query as JSON by adding ``.json`` to
the base URL.
+.. _sql_parameters:
+
Named parameters
----------------
@@ -51,6 +55,8 @@ statements can be used to change database settings at runtime. If you need to
include the string "pragma" in a query you can do so safely using a named
parameter.
+.. _sql_views:
+
Views
-----
From 7633b9ab249b2dce5ee0b4fcf9542c13a1703ef0 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 10:01:03 -0700
Subject: [PATCH 0102/1871] unauthenticated: true method plus allow block docs,
closes #825
---
datasette/utils/__init__.py | 5 +-
docs/authentication.rst | 142 +++++++++++++++++++++++++-----------
docs/internals.rst | 11 ++-
tests/test_auth.py | 24 ------
tests/test_permissions.py | 37 ++++++++++
tests/test_utils.py | 10 ++-
6 files changed, 154 insertions(+), 75 deletions(-)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 5873fcaa..51373c46 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -867,10 +867,11 @@ async def async_call_with_supported_arguments(fn, **kwargs):
def actor_matches_allow(actor, allow):
- if actor is None:
- actor = {"anonymous": True}
+ if actor is None and allow and allow.get("unauthenticated") is True:
+ return True
if allow is None:
return True
+ actor = actor or {}
for key, values in allow.items():
if values == "*" and key in actor:
return True
diff --git a/docs/authentication.rst b/docs/authentication.rst
index e26c8fc5..a9537a20 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -64,6 +64,91 @@ An **action** is a string describing the action the actor would like to perfom.
A **resource** is the item the actor wishes to interact with - for example a specific database or table. Some actions, such as ``permissions-debug``, are not associated with a particular resource.
+Datasette's built-in view permissions (``view-database``, ``view-table`` etc) default to *allow* - unless you :ref:`configure additional permission rules ` unauthenticated users will be allowed to access content.
+
+Permissions with potentially harmful effects should default to *deny*. Plugin authors should account for this when designing new plugins - for example, the `datasette-upload-csvs `__ plugin defaults to deny so that installations don't accidentally allow unauthenticated users to create new tables by uploading a CSV file.
+
+.. _authentication_permissions_allow:
+
+Defining permissions with "allow" blocks
+----------------------------------------
+
+The standard way to define permissions in Datasette is to use an ``"allow"`` block. This is a JSON document describing which actors are allowed to perfom a permission.
+
+The most basic form of allow block is this:
+
+.. code-block:: json
+
+ {
+ "allow": {
+ "id": "root"
+ }
+ }
+
+This will match any actors with an ``"id"`` property of ``"root"`` - for example, an actor that looks like this:
+
+.. code-block:: json
+
+ {
+ "id": "root",
+ "name": "Root User"
+ }
+
+Allow keys can provide a list of values. These will match any actor that has any of those values.
+
+.. code-block:: json
+
+ {
+ "allow": {
+ "id": ["simon", "cleopaws"]
+ }
+ }
+
+This will match any actor with an ``"id"`` of either ``"simon"`` or ``"cleopaws"``.
+
+Actors can have properties that feature a list of values. These will be matched against the list of values in an allow block. Consider the following actor:
+
+.. code-block:: json
+
+ {
+ "id": "simon",
+ "roles": ["staff", "developer"]
+ }
+
+This allow block will provide access to any actor that has ``"developer"`` as one of their roles:
+
+.. code-block:: json
+
+ {
+ "allow": {
+ "roles": ["developer"]
+ }
+ }
+
+Note that "roles" is not a concept that is baked into Datasette - it's a convention that plugins can choose to implement and act on.
+
+If you want to provide access to any actor with a value for a specific key, use ``"*"``. For example, to match any logged-in user specify the following:
+
+.. code-block:: json
+
+ {
+ "allow": {
+ "id": "*"
+ }
+ }
+
+You can specify that unauthenticated actors (from anynomous HTTP requests) should be allowed access using the special ``"unauthenticated": true`` key in an allow block:
+
+.. code-block:: json
+
+ {
+ "allow": {
+ "unauthenticated": true
+ }
+ }
+
+Allow keys act as an "or" mechanism. An actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block.
+
.. _authentication_permissions_metadata:
Configuring permissions in metadata.json
@@ -96,49 +181,6 @@ Here's how to restrict access to your entire Datasette instance to just the ``"i
}
}
-To allow any of the actors with an ``id`` matching a specific list of values, use this:
-
-.. code-block:: json
-
- {
- "allow": {
- "id": ["simon", "cleopaws"]
- }
- }
-
-This works for other keys as well. Imagine an actor that looks like this:
-
-.. code-block:: json
-
- {
- "id": "simon",
- "roles": ["staff", "developer"]
- }
-
-You can provide access to any user that has "developer" as one of their roles like so:
-
-.. code-block:: json
-
- {
- "allow": {
- "roles": ["developer"]
- }
- }
-
-Note that "roles" is not a concept that is baked into Datasette - it's a convention that plugins can choose to implement and act on.
-
-If you want to provide access to any actor with a value for a specific key, use ``"*"``. For example, to spceify that a query can be accessed by any logged-in user use this:
-
-.. code-block:: json
-
- {
- "allow": {
- "id": "*"
- }
- }
-
-These keys act as an "or" mechanism. A actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block.
-
.. _authentication_permissions_database:
Controlling access to specific databases
@@ -297,6 +339,8 @@ view-instance
Top level permission - Actor is allowed to view any pages within this instance, starting at https://latest.datasette.io/
+Default *allow*.
+
.. _permissions_view_database:
view-database
@@ -307,6 +351,8 @@ Actor is allowed to view a database page, e.g. https://latest.datasette.io/fixtu
``resource`` - string
The name of the database
+Default *allow*.
+
.. _permissions_view_database_download:
view-database-download
@@ -317,6 +363,8 @@ Actor is allowed to download a database, e.g. https://latest.datasette.io/fixtur
``resource`` - string
The name of the database
+Default *allow*.
+
.. _permissions_view_table:
view-table
@@ -327,6 +375,8 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
``resource`` - tuple: (string, string)
The name of the database, then the name of the table
+Default *allow*.
+
.. _permissions_view_query:
view-query
@@ -337,6 +387,8 @@ Actor is allowed to view a :ref:`canned query ` page, e.g. https
``resource`` - tuple: (string, string)
The name of the database, then the name of the canned query
+Default *allow*.
+
.. _permissions_execute_sql:
execute-sql
@@ -347,9 +399,13 @@ Actor is allowed to run arbitrary SQL queries against a specific database, e.g.
``resource`` - string
The name of the database
+Default *allow*.
+
.. _permissions_permissions_debug:
permissions-debug
-----------------
Actor is allowed to view the ``/-/permissions`` debug page.
+
+Default *deny*.
\ No newline at end of file
diff --git a/docs/internals.rst b/docs/internals.rst
index df21eb09..8136d8ac 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -184,11 +184,16 @@ await .permission_allowed(actor, action, resource=None, default=False)
``resource`` - string, optional
The resource, e.g. the name of the table. Only some permissions apply to a resource.
-Check if the given actor has permission to perform the given action on the given resource. This uses plugins that implement the :ref:`plugin_permission_allowed` plugin hook to decide if the action is allowed or not.
+``default`` - optional, True or False
+ Should this permission check be default allow or default deny.
-If none of the plugins express an opinion, the return value will be the ``default`` argument. This is deny, but you can pass ``default=True`` to default allow instead.
+Check if the given actor has :ref:`permission ` to perform the given action on the given resource.
-See :ref:`permissions` for a full list of permissions included in Datasette core.
+Some permission checks are carried out against :ref:`rules defined in metadata.json `, while other custom permissions may be decided by plugins that implement the :ref:`plugin_permission_allowed` plugin hook.
+
+If neither ``metadata.json`` nor any of the plugins provide an answer to the permission query the ``default`` argument will be returned.
+
+See :ref:`permissions` for a full list of permission actions included in Datasette core.
.. _datasette_get_database:
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 40dc2587..0e5563a3 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,5 +1,4 @@
from .fixtures import app_client
-from bs4 import BeautifulSoup as Soup
def test_auth_token(app_client):
@@ -20,26 +19,3 @@ def test_actor_cookie(app_client):
cookie = app_client.ds.sign({"id": "test"}, "actor")
response = app_client.get("/", cookies={"ds_actor": cookie})
assert {"id": "test"} == app_client.ds._last_request.scope["actor"]
-
-
-def test_permissions_debug(app_client):
- app_client.ds._permission_checks.clear()
- assert 403 == app_client.get("/-/permissions").status
- # With the cookie it should work
- cookie = app_client.ds.sign({"id": "root"}, "actor")
- response = app_client.get("/-/permissions", cookies={"ds_actor": cookie})
- # Should show one failure and one success
- soup = Soup(response.body, "html.parser")
- check_divs = soup.findAll("div", {"class": "check"})
- checks = [
- {
- "action": div.select_one(".check-action").text,
- "result": bool(div.select(".check-result-true")),
- "used_default": bool(div.select(".check-used-default")),
- }
- for div in check_divs
- ]
- assert [
- {"action": "permissions-debug", "result": True, "used_default": False},
- {"action": "permissions-debug", "result": False, "used_default": True},
- ] == checks
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index d8c98825..c088facd 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -1,4 +1,5 @@
from .fixtures import app_client, assert_permissions_checked, make_app_client
+from bs4 import BeautifulSoup as Soup
import pytest
@@ -283,3 +284,39 @@ def test_permissions_checked(app_client, path, permissions):
response = app_client.get(path)
assert response.status in (200, 403)
assert_permissions_checked(app_client.ds, permissions)
+
+
+def test_permissions_debug(app_client):
+ app_client.ds._permission_checks.clear()
+ assert 403 == app_client.get("/-/permissions").status
+ # With the cookie it should work
+ cookie = app_client.ds.sign({"id": "root"}, "actor")
+ response = app_client.get("/-/permissions", cookies={"ds_actor": cookie})
+ # Should show one failure and one success
+ soup = Soup(response.body, "html.parser")
+ check_divs = soup.findAll("div", {"class": "check"})
+ checks = [
+ {
+ "action": div.select_one(".check-action").text,
+ "result": bool(div.select(".check-result-true")),
+ "used_default": bool(div.select(".check-used-default")),
+ }
+ for div in check_divs
+ ]
+ assert [
+ {"action": "permissions-debug", "result": True, "used_default": False},
+ {"action": "permissions-debug", "result": False, "used_default": True},
+ ] == checks
+
+
+@pytest.mark.parametrize("allow,expected", [
+ ({"id": "root"}, 403),
+ ({"id": "root", "unauthenticated": True}, 200),
+])
+def test_allow_unauthenticated(allow, expected):
+ with make_app_client(
+ metadata={
+ "allow": allow
+ }
+ ) as client:
+ assert expected == client.get("/").status
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 4bade18b..0ffe8ae6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -464,12 +464,16 @@ def test_multi_params(data, should_raise):
@pytest.mark.parametrize(
"actor,allow,expected",
[
- ({"id": "root"}, None, True),
- ({"id": "root"}, {}, False),
- ({"anonymous": True}, {"anonymous": True}, True),
(None, None, True),
(None, {}, False),
(None, {"id": "root"}, False),
+ ({"id": "root"}, None, True),
+ ({"id": "root"}, {}, False),
+ ({"id": "simon", "staff": True}, {"staff": True}, True),
+ ({"id": "simon", "staff": False}, {"staff": True}, False),
+ # Special case for "unauthenticated": true
+ (None, {"unauthenticated": True}, True),
+ (None, {"unauthenticated": False}, False),
# Special "*" value for any key:
({"id": "root"}, {"id": "*"}, True),
({}, {"id": "*"}, False),
From 5ef3b7b0c9b9e318af711bbd03e84af2abffdc29 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 12:25:44 -0700
Subject: [PATCH 0103/1871] Applied Black
Refs #825
---
tests/test_permissions.py | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index c088facd..477b8160 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -309,14 +309,10 @@ def test_permissions_debug(app_client):
] == checks
-@pytest.mark.parametrize("allow,expected", [
- ({"id": "root"}, 403),
- ({"id": "root", "unauthenticated": True}, 200),
-])
+@pytest.mark.parametrize(
+ "allow,expected",
+ [({"id": "root"}, 403), ({"id": "root", "unauthenticated": True}, 200),],
+)
def test_allow_unauthenticated(allow, expected):
- with make_app_client(
- metadata={
- "allow": allow
- }
- ) as client:
+ with make_app_client(metadata={"allow": allow}) as client:
assert expected == client.get("/").status
From 56eb80a45925d804b443701e2c86315f194b5f7d Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 12:32:52 -0700
Subject: [PATCH 0104/1871] Documented CSRF protection, closes #827
---
docs/internals.rst | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/docs/internals.rst b/docs/internals.rst
index 8136d8ac..d92c985f 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -51,7 +51,7 @@ The request object is passed to various plugin hooks. It represents an incoming
The object also has one awaitable method:
``await request.post_vars()`` - dictionary
- Returns a dictionary of form variables that were submitted in the request body via ``POST``.
+ Returns a dictionary of form variables that were submitted in the request body via ``POST``. Don't forget to read about :ref:`internals_csrf`!
.. _internals_multiparams:
@@ -500,3 +500,17 @@ The ``Database`` class also provides properties and methods for introspecting th
}
]
}
+
+
+.. _internals_csrf:
+
+CSRF protection
+~~~~~~~~~~~~~~~
+
+Datasette uses `asgi-csrf `__ to guard against CSRF attacks on form POST submissions. Users receive a ``ds_csrftoken`` cookie which is compared against the ``csrftoken`` form field (or ``x-csrftoken`` HTTP header) for every incoming request.
+
+If your plugin implements a ```` anywhere you will need to include that token. You can do so with the following template snippet:
+
+.. code-block:: html
+
+
From f240970b834d595947c8d27d46d1f19b9119376d Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 12:57:54 -0700
Subject: [PATCH 0105/1871] Fixed tests/fixtures.py, closes #804
---
docs/contributing.rst | 13 +++++-
tests/fixtures.py | 97 ++++++++++++++++++++++++-------------------
2 files changed, 65 insertions(+), 45 deletions(-)
diff --git a/docs/contributing.rst b/docs/contributing.rst
index da4dc35a..9c44d177 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -70,11 +70,20 @@ You can also use the ``fixtures.py`` script to recreate the testing version of `
python tests/fixtures.py fixtures.db fixtures-metadata.json
-(You may need to delete ``fixtures.db`` before running this command.)
+Or to output the plugins used by the tests, run this::
+
+ python tests/fixtures.py fixtures.db fixtures-metadata.json fixtures-plugins
+ Test tables written to fixtures.db
+ - metadata written to fixtures-metadata.json
+ Wrote plugin: fixtures-plugins/register_output_renderer.py
+ Wrote plugin: fixtures-plugins/view_name.py
+ Wrote plugin: fixtures-plugins/my_plugin.py
+ Wrote plugin: fixtures-plugins/messages_output_renderer.py
+ Wrote plugin: fixtures-plugins/my_plugin_2.py
Then run Datasette like this::
- datasette fixtures.db -m fixtures-metadata.json
+ datasette fixtures.db -m fixtures-metadata.json --plugins-dir=fixtures-plugins/
.. _contributing_documentation:
diff --git a/tests/fixtures.py b/tests/fixtures.py
index a51a869d..1eb1bb6e 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -2,6 +2,7 @@ from datasette.app import Datasette
from datasette.utils import sqlite3, MultiParams
from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync
+import click
import contextlib
from http.cookies import SimpleCookie
import itertools
@@ -813,49 +814,6 @@ INSERT INTO "searchable_fts" (rowid, text1, text2)
SELECT rowid, text1, text2 FROM searchable;
"""
-if __name__ == "__main__":
- # Can be called with data.db OR data.db metadata.json
- arg_index = -1
- db_filename = sys.argv[arg_index]
- metadata_filename = None
- plugins_path = None
- if db_filename.endswith("/"):
- # It's the plugins dir
- plugins_path = db_filename
- arg_index -= 1
- db_filename = sys.argv[arg_index]
- if db_filename.endswith(".json"):
- metadata_filename = db_filename
- arg_index -= 1
- db_filename = sys.argv[arg_index]
- if db_filename.endswith(".db"):
- conn = sqlite3.connect(db_filename)
- conn.executescript(TABLES)
- for sql, params in TABLE_PARAMETERIZED_SQL:
- with conn:
- conn.execute(sql, params)
- print("Test tables written to {}".format(db_filename))
- if metadata_filename:
- open(metadata_filename, "w").write(json.dumps(METADATA))
- print("- metadata written to {}".format(metadata_filename))
- if plugins_path:
- path = pathlib.Path(plugins_path)
- if not path.exists():
- path.mkdir()
- for filename, content in (
- ("my_plugin.py", PLUGIN1),
- ("my_plugin_2.py", PLUGIN2),
- ):
- filepath = path / filename
- filepath.write_text(content)
- print(" Wrote plugin: {}".format(filepath))
- else:
- print(
- "Usage: {} db_to_write.db [metadata_to_write.json] [plugins-dir/]".format(
- sys.argv[0]
- )
- )
-
def assert_permissions_checked(datasette, actions):
# actions is a list of "action" or (action, resource) tuples
@@ -873,3 +831,56 @@ def assert_permissions_checked(datasette, actions):
""".format(
action, resource, json.dumps(list(datasette._permission_checks), indent=4),
)
+
+
+@click.command()
+@click.argument(
+ "db_filename",
+ default="fixtures.db",
+ type=click.Path(file_okay=True, dir_okay=False),
+)
+@click.argument("metadata", required=False)
+@click.argument(
+ "plugins_path", type=click.Path(file_okay=False, dir_okay=True), required=False
+)
+@click.option(
+ "--recreate",
+ is_flag=True,
+ default=False,
+ help="Delete and recreate database if it exists",
+)
+def cli(db_filename, metadata, plugins_path, recreate):
+ "Write out the fixtures database used by Datasette's test suite"
+ if metadata and not metadata.endswith(".json"):
+ raise click.ClickException("Metadata should end with .json")
+ if not db_filename.endswith(".db"):
+ raise click.ClickException("Database file should end with .db")
+ if pathlib.Path(db_filename).exists():
+ if not recreate:
+ raise click.ClickException(
+ "{} already exists, use --recreate to reset it".format(db_filename)
+ )
+ else:
+ pathlib.Path(db_filename).unlink()
+ conn = sqlite3.connect(db_filename)
+ conn.executescript(TABLES)
+ for sql, params in TABLE_PARAMETERIZED_SQL:
+ with conn:
+ conn.execute(sql, params)
+ print("Test tables written to {}".format(db_filename))
+ if metadata:
+ open(metadata, "w").write(json.dumps(METADATA, indent=4))
+ print("- metadata written to {}".format(metadata))
+ if plugins_path:
+ path = pathlib.Path(plugins_path)
+ if not path.exists():
+ path.mkdir()
+ test_plugins = pathlib.Path(__file__).parent / "plugins"
+ for filepath in test_plugins.glob("*.py"):
+ newpath = path / filepath.name
+ newpath.write_text(filepath.open().read())
+ print(" Wrote plugin: {}".format(newpath))
+
+
+if __name__ == "__main__":
+ cli()
From 008e2f63c217aa066027a872ee706b07bd084857 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 15:19:37 -0700
Subject: [PATCH 0106/1871] response.set_cookie(), closes #795
---
datasette/actor_auth_cookie.py | 1 -
datasette/app.py | 15 ++-------
datasette/utils/asgi.py | 53 +++++++++++++++++++++++++++++---
datasette/views/special.py | 14 ++-------
docs/internals.rst | 30 ++++++++++++++++++
tests/test_internals_response.py | 26 ++++++++++++++++
6 files changed, 108 insertions(+), 31 deletions(-)
diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py
index f3a0f306..a2aa6889 100644
--- a/datasette/actor_auth_cookie.py
+++ b/datasette/actor_auth_cookie.py
@@ -1,6 +1,5 @@
from datasette import hookimpl
from itsdangerous import BadSignature
-from http.cookies import SimpleCookie
@hookimpl
diff --git a/datasette/app.py b/datasette/app.py
index 633ca4fe..71fa9afb 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -3,7 +3,6 @@ import asgi_csrf
import collections
import datetime
import hashlib
-from http.cookies import SimpleCookie
import itertools
import json
import os
@@ -442,19 +441,9 @@ class Datasette:
def _write_messages_to_response(self, request, response):
if getattr(request, "_messages", None):
# Set those messages
- cookie = SimpleCookie()
- cookie["ds_messages"] = self.sign(request._messages, "messages")
- cookie["ds_messages"]["path"] = "/"
- # TODO: Co-exist with existing set-cookie headers
- assert "set-cookie" not in response.headers
- response.headers["set-cookie"] = cookie.output(header="").lstrip()
+ response.set_cookie("ds_messages", self.sign(request._messages, "messages"))
elif getattr(request, "_messages_should_clear", False):
- cookie = SimpleCookie()
- cookie["ds_messages"] = ""
- cookie["ds_messages"]["path"] = "/"
- # TODO: Co-exist with existing set-cookie headers
- assert "set-cookie" not in response.headers
- response.headers["set-cookie"] = cookie.output(header="").lstrip()
+ response.set_cookie("ds_messages", "", expires=0, max_age=0)
def _show_messages(self, request):
if getattr(request, "_messages", None):
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index cdd6b148..5a152570 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -4,10 +4,15 @@ from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path
from html import escape
-from http.cookies import SimpleCookie
+from http.cookies import SimpleCookie, Morsel
import re
import aiofiles
+# Workaround for adding samesite support to pre 3.8 python
+Morsel._reserved["samesite"] = "SameSite"
+# Thanks, Starlette:
+# https://github.com/encode/starlette/blob/519f575/starlette/responses.py#L17
+
class NotFound(Exception):
pass
@@ -17,6 +22,9 @@ class Forbidden(Exception):
pass
+SAMESITE_VALUES = ("strict", "lax", "none")
+
+
class Request:
def __init__(self, scope, receive):
self.scope = scope
@@ -370,20 +378,24 @@ class Response:
self.body = body
self.status = status
self.headers = headers or {}
+ self._set_cookie_headers = []
self.content_type = content_type
async def asgi_send(self, send):
headers = {}
headers.update(self.headers)
headers["content-type"] = self.content_type
+ raw_headers = [
+ [key.encode("utf-8"), value.encode("utf-8")]
+ for key, value in headers.items()
+ ]
+ for set_cookie in self._set_cookie_headers:
+ raw_headers.append([b"set-cookie", set_cookie.encode("utf-8")])
await send(
{
"type": "http.response.start",
"status": self.status,
- "headers": [
- [key.encode("utf-8"), value.encode("utf-8")]
- for key, value in headers.items()
- ],
+ "headers": raw_headers,
}
)
body = self.body
@@ -391,6 +403,37 @@ class Response:
body = body.encode("utf-8")
await send({"type": "http.response.body", "body": body})
+ def set_cookie(
+ self,
+ key,
+ value="",
+ max_age=None,
+ expires=None,
+ path="/",
+ domain=None,
+ secure=False,
+ httponly=False,
+ samesite="lax",
+ ):
+ assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
+ SAMESITE_VALUES
+ )
+ cookie = SimpleCookie()
+ cookie[key] = value
+ for prop_name, prop_value in (
+ ("max_age", max_age),
+ ("expires", expires),
+ ("path", path),
+ ("domain", domain),
+ ("samesite", samesite),
+ ):
+ if prop_value is not None:
+ cookie[key][prop_name.replace("_", "-")] = prop_value
+ for prop_name, prop_value in (("secure", secure), ("httponly", httponly)):
+ if prop_value:
+ cookie[key][prop_name] = True
+ self._set_cookie_headers.append(cookie.output(header="").strip())
+
@classmethod
def html(cls, body, status=200, headers=None):
return cls(
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 7a5fbe21..7f4284a1 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -1,7 +1,6 @@
import json
from datasette.utils.asgi import Response
from .base import BaseView
-from http.cookies import SimpleCookie
import secrets
@@ -62,17 +61,8 @@ class AuthTokenView(BaseView):
return Response("Root token has already been used", status=403)
if secrets.compare_digest(token, self.ds._root_token):
self.ds._root_token = None
- cookie = SimpleCookie()
- cookie["ds_actor"] = self.ds.sign({"id": "root"}, "actor")
- cookie["ds_actor"]["path"] = "/"
- response = Response(
- body="",
- status=302,
- headers={
- "Location": "/",
- "set-cookie": cookie.output(header="").lstrip(),
- },
- )
+ response = Response.redirect("/")
+ response.set_cookie("ds_actor", self.ds.sign({"id": "root"}, "actor"))
return response
else:
return Response("Invalid token", status=403)
diff --git a/docs/internals.rst b/docs/internals.rst
index d92c985f..7978e3d7 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -131,6 +131,36 @@ Each of these responses will use the correct corresponding content-type - ``text
Each of the helper methods take optional ``status=`` and ``headers=`` arguments, documented above.
+.. _internals_response_set_cookie:
+
+Setting cookies with response.set_cookie()
+------------------------------------------
+
+To set cookies on the response, use the ``response.set_cookie(...)`` method. The method signature looks like this:
+
+.. code-block:: python
+
+ def set_cookie(
+ self,
+ key,
+ value="",
+ max_age=None,
+ expires=None,
+ path="/",
+ domain=None,
+ secure=False,
+ httponly=False,
+ samesite="lax",
+ ):
+
+You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the ``ds_actor`` cookie for use with Datasette :ref:`authentication `:
+
+.. code-block:: python
+
+ response = Response.redirect("/")
+ response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
+ return response
+
.. _internals_datasette:
Datasette class
diff --git a/tests/test_internals_response.py b/tests/test_internals_response.py
index 7c11f858..820b20b2 100644
--- a/tests/test_internals_response.py
+++ b/tests/test_internals_response.py
@@ -1,4 +1,5 @@
from datasette.utils.asgi import Response
+import pytest
def test_response_html():
@@ -26,3 +27,28 @@ def test_response_redirect():
response = Response.redirect("/foo")
assert 302 == response.status
assert "/foo" == response.headers["Location"]
+
+
+@pytest.mark.asyncio
+async def test_response_set_cookie():
+ events = []
+
+ async def send(event):
+ events.append(event)
+
+ response = Response.redirect("/foo")
+ response.set_cookie("foo", "bar", max_age=10, httponly=True)
+ await response.asgi_send(send)
+
+ assert [
+ {
+ "type": "http.response.start",
+ "status": 302,
+ "headers": [
+ [b"Location", b"/foo"],
+ [b"content-type", b"text/plain"],
+ [b"set-cookie", b"foo=bar; HttpOnly; Max-Age=10; Path=/; SameSite=lax"],
+ ],
+ },
+ {"type": "http.response.body", "body": b""},
+ ] == events
From b5f04f42ab56be90735e1df9660e334089fbd6aa Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 15:32:24 -0700
Subject: [PATCH 0107/1871] ds_actor cookie documentation, closes #826
---
docs/authentication.rst | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index a9537a20..f511e373 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -315,8 +315,8 @@ The currently authenticated actor is made available to plugins as ``request.acto
.. _PermissionsDebugView:
-Permissions Debug
-=================
+The permissions debug tool
+==========================
The debug tool at ``/-/permissions`` is only available to the :ref:`authenticated root user ` (or any actor granted the ``permissions-debug`` action according to a plugin).
@@ -324,6 +324,22 @@ It shows the thirty most recent permission checks that have been carried out by
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.
+.. _authentication_ds_actor:
+
+The ds_actor cookie
+===================
+
+Datasette includes a default authentication plugin which looks for a signed ``ds_actor`` cookie containing a JSON actor dictionary. This is how the :ref:`root actor ` mechanism works.
+
+Authentication plugins can set signed ``ds_actor`` cookies themselves like so:
+
+.. code-block:: python
+
+ response = Response.redirect("/")
+ response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
+ return response
+
+Note that you need to pass ``"actor"`` as the namespace to :ref:`datasette_sign`.
.. _permissions:
From b3919d8059a519eb7709f0b4fa1561fec219bc98 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 16:03:42 -0700
Subject: [PATCH 0108/1871] Mostly complete release notes for 0.44, refs #806
---
docs/changelog.rst | 140 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 140 insertions(+)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 8b6272cb..e4e6057b 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,146 @@
Changelog
=========
+.. _v0_44:
+
+0.44 (2020-06-??)
+-----------------
+
+Authentication and permissions, writable canned queries, flash messages, new plugin hooks and more.
+
+Authentication
+~~~~~~~~~~~~~~
+
+Prior to this release the Datasette ecosystem has treated authentication as exclusively the realm of plugins, most notably through `datasette-auth-github `__.
+
+0.44 introduces :ref:`authentication` as core Datasette concepts (`#699 `__). This makes it easier for different plugins can share responsibility for authenticating requests - you might have one plugin that handles user accounts and another one that allows automated access via API keys, for example.
+
+You'll need to install plugins if you want full user accounts, but default Datasette can now authenticate a single root user with the new ``--root`` command-line option, which outputs a one-time use URL to :ref:`authenticate as a root actor ` (`#784 `__)::
+
+ $ datasette fixtures.db --root
+ http://127.0.0.1:8001/-/auth-token?token=5b632f8cd44b868df625f5a6e2185d88eea5b22237fd3cc8773f107cc4fd6477
+ INFO: Started server process [14973]
+ INFO: Waiting for application startup.
+ INFO: Application startup complete.
+ INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)
+
+Plugins can implement new ways of authenticating users using the new :ref:`plugin_actor_from_request` hook.
+
+Permissions
+~~~~~~~~~~~
+
+Datasette also now has a built-in concept of :ref:`authentication_permissions`. The permissions system answers the following question:
+
+ Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
+
+You can use the new ``"allow"`` block syntax in ``metadata.json`` (or ``metadata.yaml``) to set required permissions at the instance, database, table or canned query level. For example, to restrict access to the ``fixtures.db`` database to the ``"root"`` user:
+
+.. code-block:: json
+
+ {
+ "databases": {
+ "fixtures": {
+ "allow": {
+ "id" "root"
+ }
+ }
+ }
+ }
+
+See :ref:`authentication_permissions_allow` for more details.
+
+Plugins can implement their own custom permission checks using the new :ref:`plugin_permission_allowed` hook.
+
+A new debug page at ``/-/permissions`` shows recent permission checks, to help administrators and plugin authors understand exactly what checks are being performed. This tool defaults to only being available to the root user, but can be exposed to other users by plugins that respond to the ``permissions-debug`` permission. (`#788 `__)
+
+Writable canned queries
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Datasette's :ref:`canned_queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
+
+Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 introduces the ability for canned queries to execute ``INSERT`` or ``UPDATE`` queries as well, using the new ``"write": true`` property (`#800 `__):
+
+.. code-block:: json
+
+ {
+ "databases": {
+ "dogs": {
+ "queries": {
+ "add_name": {
+ "sql": "INSERT INTO names (name) VALUES (:name)",
+ "write": true
+ }
+ }
+ }
+ }
+ }
+
+See :ref:`canned_queries_writable` for more details.
+
+Flash messages
+~~~~~~~~~~~~~~
+
+Writable canned queries needed a mechanism to let the user know that the query has been successfully executed. The new flash messaging system (`#790 `__) allows messages to persist in signed cookies which are then displayed to the user on the next page that they visit. Plugins can use this mechanism to display their own messages, see :ref:`datasette_add_message` for details.
+
+You can try out the new messages using the ``/-/messages`` debug tool, for example at https://latest.datasette.io/-/messages
+
+Signed values and secrets
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Both flash messages and user authentication needed a way to sign values and set signed cookies. Two new methods are now available for plugins to take advantage of this mechanism: :ref:`datasette_sign` and :ref:`datasette_unsign`.
+
+Datasette will generate a secret automatically when it starts up, but to avoid resetting the secret (and hence invalidating any cookies) every time the server restarts you should set your own secret. You can pass a secret to Datasette using the new ``--secret`` option or with a ``DATASETTE_SECRET`` environment variable. See :ref:`config_secret` for more details.
+
+Plugins can now sign value and verify their signatures using the :ref:`datasette.sign() ` and :ref:`datasette.unsign() ` methods.
+
+CSRF protection
+~~~~~~~~~~~~~~~
+
+Since writable canned queries are built using POST forms, Datasette now ships with :ref:`internals_csrf` (`#798 `__). This applies automatically to any POST request, which means plugins need to include a ``csrftoken`` in any POST forms that they render. They can do that like so:
+
+.. code-block:: html
+
+
+
+register_routes() plugin hooks
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Plugins can now register new views and routes via the :ref:`plugin_register_routes` plugin hook (`#819 `__). View functions can be defined that accept any of the current ``datasette`` object, the current ``request``, or the ASGI ``scope``, ``send`` and ``receive`` objects.
+
+Smaller changes
+~~~~~~~~~~~~~~~
+
+- New internals documentation for :ref:`internals_request` and :ref:`internals_response`. (`#706 `__)
+- ``request.url`` now respects the ``force_https_urls`` config setting. closes (`#781 `__)
+- ``request.args.getlist()`` returns ``[]`` if missing. Removed ``request.raw_args`` entirely. (`#774 `__)
+- New :ref:`datasette.get_database() ` method.
+- Added ``_`` prefix to many private, undocumented methods of the Datasette class. (`#576 `__)
+- Removed the ``db.get_outbound_foreign_keys()`` method which duplicated the behaviour of ``db.foreign_keys_for_table()``.
+- New :ref:`await datasette.permission_allowed() ` method.
+- ``/-/actor`` debugging endpoint for viewing the currently authenticated actor.
+- New ``request.cookies`` property.
+- ``/-/plugins`` endpoint now shows a list of hooks implemented by each plugin, e.g. https://latest.datasette.io/-/plugins?all=1
+- ``request.post_vars()`` method no longer discards empty values.
+- New "params" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (`#797 `__)
+- ``request.args`` is now a :ref:`MultiParams ` object.
+- Fixed a bug with the ``datasette plugins`` command. (`#802 `__)
+- Nicer pattern for using ``make_app_client()`` in tests. (`#395 `__)
+- New ``request.actor`` property.
+- Fixed broken CSS on nested 404 pages. (`#777 `__)
+- New ``request.url_vars`` property. (`#822 `__)
+- Fixed a bug with the ``python tests/fixtures.py`` command for outputting Datasette's testing fixtures database and plugins. (`#804 `__)
+
+The road to Datasette 1.0
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+I've assembled a `milestone for Datasette 1.0 `__. The focus of the 1.0 release will be the following:
+
+- Signify confidence in the quality/stability of Datasette
+- Give plugin authors confidence that their plugins will work for the whole 1.x release cycle
+- Provide the same confidence to developers building against Datasette JSON APIs
+
+If you have thoughts about what you would like to see for Datasette 1.0 you can join `the conversation on issue #519 `__.
+
.. _v0_43:
0.43 (2020-05-28)
From d94fc39e33b5eccae853e62f54bd8cc8e74688ff Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 16:43:58 -0700
Subject: [PATCH 0109/1871] Crafty JavaScript trick for generating commit
references
---
docs/contributing.rst | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 9c44d177..6562afc8 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -156,6 +156,18 @@ To release a new version, first create a commit that updates :ref:`the changelog
Referencing the issues that are part of the release in the commit message ensures the name of the release shows up on those issue pages, e.g. `here `__.
+You can generate the list of issue references for a specific release by pasting the following into the browser devtools while looking at the :ref:`changelog` page (replace ``v0-44`` with the most recent version):
+
+.. code-block:: javascript
+
+ [
+ ...new Set(
+ Array.from(
+ document.getElementById("v0-44").querySelectorAll("a[href*=issues]")
+ ).map((a) => "#" + a.href.split("/issues/")[1])
+ ),
+ ].sort().join(", ");
+
For non-bugfix releases you may want to update the news section of ``README.md`` as part of the same commit.
To tag and push the releaes, run the following::
From f3951539f1750698976359411e19c1ccb79210ed Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 18:19:11 -0700
Subject: [PATCH 0110/1871] Hopefully fix horizontal scroll with changelog on
mobile
---
docs/changelog.rst | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index e4e6057b..911fb1b6 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1051,9 +1051,7 @@ request all rows where that column is less than 50 meters or more than 20 feet f
- Fix SQLite error when loading rows with no incoming FKs. [Russ
Garrett]
- This fixes ``ERROR: conn=, sql
- = 'select ', params = {'id': '1'}`` caused by an invalid query when
- loading incoming FKs.
+ This fixes an error caused by an invalid query when loading incoming FKs.
The error was ignored due to async but it still got printed to the
console.
From d828abaddec0dce3ec4b4eeddc3a74384e52cf34 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Tue, 9 Jun 2020 21:20:07 -0700
Subject: [PATCH 0111/1871] Fix horizontal scrollbar on changelog, refs #828
---
docs/_static/css/custom.css | 3 +++
docs/conf.py | 5 +++++
2 files changed, 8 insertions(+)
create mode 100644 docs/_static/css/custom.css
diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css
new file mode 100644
index 00000000..d7c2f164
--- /dev/null
+++ b/docs/_static/css/custom.css
@@ -0,0 +1,3 @@
+a.external {
+ overflow-wrap: anywhere;
+}
diff --git a/docs/conf.py b/docs/conf.py
index 5e0bb328..b273afca 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -99,6 +99,11 @@ html_theme = "sphinx_rtd_theme"
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
+html_css_files = [
+ "css/custom.css",
+]
+
+
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
From 57e812d5de9663a3c177e0344f4d1e552a74d484 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Wed, 10 Jun 2020 12:39:54 -0700
Subject: [PATCH 0112/1871] ds_author cookie can now expire, closes #829
Refs https://github.com/simonw/datasette-auth-github/issues/62#issuecomment-642152076
---
datasette/actor_auth_cookie.py | 13 ++++++++-
datasette/views/special.py | 4 ++-
docs/authentication.rst | 48 ++++++++++++++++++++++++++++++++--
docs/internals.rst | 4 +--
setup.py | 1 +
tests/fixtures.py | 3 +++
tests/test_auth.py | 21 +++++++++++++--
tests/test_canned_write.py | 6 ++---
tests/test_permissions.py | 20 +++++++-------
9 files changed, 99 insertions(+), 21 deletions(-)
diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py
index a2aa6889..15ecd331 100644
--- a/datasette/actor_auth_cookie.py
+++ b/datasette/actor_auth_cookie.py
@@ -1,5 +1,7 @@
from datasette import hookimpl
from itsdangerous import BadSignature
+import baseconv
+import time
@hookimpl
@@ -7,6 +9,15 @@ def actor_from_request(datasette, request):
if "ds_actor" not in request.cookies:
return None
try:
- return datasette.unsign(request.cookies["ds_actor"], "actor")
+ decoded = datasette.unsign(request.cookies["ds_actor"], "actor")
+ # If it has "e" and "a" keys process the "e" expiry
+ if not isinstance(decoded, dict) or "a" not in decoded:
+ return None
+ expires_at = decoded.get("e")
+ if expires_at:
+ timestamp = int(baseconv.base62.decode(expires_at))
+ if time.time() > timestamp:
+ return None
+ return decoded["a"]
except BadSignature:
return None
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 7f4284a1..dc6a25dc 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -62,7 +62,9 @@ class AuthTokenView(BaseView):
if secrets.compare_digest(token, self.ds._root_token):
self.ds._root_token = None
response = Response.redirect("/")
- response.set_cookie("ds_actor", self.ds.sign({"id": "root"}, "actor"))
+ response.set_cookie(
+ "ds_actor", self.ds.sign({"a": {"id": "root"}}, "actor")
+ )
return response
else:
return Response("Invalid token", status=403)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index f511e373..9b66132a 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -336,11 +336,55 @@ Authentication plugins can set signed ``ds_actor`` cookies themselves like so:
.. code-block:: python
response = Response.redirect("/")
- response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
- return response
+ response.set_cookie("ds_actor", datasette.sign({
+ "a": {
+ "id": "cleopaws"
+ }
+ }, "actor"))
Note that you need to pass ``"actor"`` as the namespace to :ref:`datasette_sign`.
+The shape of data encoded in the cookie is as follows::
+
+ {
+ "a": {... actor ...}
+ }
+
+.. _authentication_ds_actor_expiry:
+
+Including an expiry time
+------------------------
+
+``ds_actor`` cookies can optionally include a signed expiry timestamp, after which the cookies will no longer be valid. Authentication plugins may chose to use this mechanism to limit the lifetime of the cookie. For example, if a plugin implements single-sign-on against another source it may decide to set short-lived cookies so that if the user is removed from the SSO system their existing Datasette cookies will stop working shortly afterwards.
+
+To include an expiry, add a ``"e"`` key to the cookie value containing a `base62-encoded integer `__ representing the timestamp when the cookie should expire. For example, here's how to set a cookie that expires after 24 hours:
+
+.. code-block:: python
+
+ import time
+ import baseconv
+
+ expires_at = int(time.time()) + (24 * 60 * 60)
+
+ response = Response.redirect("/")
+ response.set_cookie("ds_actor", datasette.sign({
+ "a": {
+ "id": "cleopaws"
+ },
+ "e": baseconv.base62.encode(expires_at),
+ }, "actor"))
+
+The resulting cookie will encode data that looks something like this:
+
+.. code-block:: json
+
+ {
+ "a": {
+ "id": "cleopaws"
+ },
+ "e": "1jjSji"
+ }
+
.. _permissions:
Built-in permissions
diff --git a/docs/internals.rst b/docs/internals.rst
index 7978e3d7..d75544e1 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -153,12 +153,12 @@ To set cookies on the response, use the ``response.set_cookie(...)`` method. The
samesite="lax",
):
-You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the ``ds_actor`` cookie for use with Datasette :ref:`authentication `:
+You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the :ref:`ds_actor cookie ` for use with Datasette :ref:`authentication `:
.. code-block:: python
response = Response.redirect("/")
- response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
+ response.set_cookie("ds_actor", datasette.sign({"a": {"id": "cleopaws"}}, "actor"))
return response
.. _internals_datasette:
diff --git a/setup.py b/setup.py
index 678a022f..45af0253 100644
--- a/setup.py
+++ b/setup.py
@@ -57,6 +57,7 @@ setup(
"PyYAML~=5.3",
"mergedeep>=1.1.1,<1.4.0",
"itsdangerous~=1.1",
+ "python-baseconv==1.2.2",
],
entry_points="""
[console_scripts]
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 1eb1bb6e..a846999b 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -109,6 +109,9 @@ class TestClient:
def __init__(self, asgi_app):
self.asgi_app = asgi_app
+ def actor_cookie(self, actor):
+ return self.ds.sign({"a": actor}, "actor")
+
@async_to_sync
async def get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 0e5563a3..5e847445 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,4 +1,7 @@
from .fixtures import app_client
+import baseconv
+import pytest
+import time
def test_auth_token(app_client):
@@ -8,7 +11,9 @@ def test_auth_token(app_client):
response = app_client.get(path, allow_redirects=False,)
assert 302 == response.status
assert "/" == response.headers["Location"]
- assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor")
+ assert {"a": {"id": "root"}} == app_client.ds.unsign(
+ response.cookies["ds_actor"], "actor"
+ )
# Check that a second with same token fails
assert app_client.ds._root_token is None
assert 403 == app_client.get(path, allow_redirects=False,).status
@@ -16,6 +21,18 @@ def test_auth_token(app_client):
def test_actor_cookie(app_client):
"A valid actor cookie sets request.scope['actor']"
- cookie = app_client.ds.sign({"id": "test"}, "actor")
+ cookie = app_client.actor_cookie({"id": "test"})
response = app_client.get("/", cookies={"ds_actor": cookie})
assert {"id": "test"} == app_client.ds._last_request.scope["actor"]
+
+
+@pytest.mark.parametrize(
+ "offset,expected", [((24 * 60 * 60), {"id": "test"}), (-(24 * 60 * 60), None),]
+)
+def test_actor_cookie_that_expires(app_client, offset, expected):
+ expires_at = int(time.time()) + offset
+ cookie = app_client.ds.sign(
+ {"a": {"id": "test"}, "e": baseconv.base62.encode(expires_at)}, "actor"
+ )
+ response = app_client.get("/", cookies={"ds_actor": cookie})
+ assert expected == app_client.ds._last_request.scope["actor"]
diff --git a/tests/test_canned_write.py b/tests/test_canned_write.py
index dc3fba3f..4257806e 100644
--- a/tests/test_canned_write.py
+++ b/tests/test_canned_write.py
@@ -55,7 +55,7 @@ def test_custom_success_message(canned_write_client):
response = canned_write_client.post(
"/data/delete_name",
{"rowid": 1},
- cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")},
+ cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
allow_redirects=False,
csrftoken_from=True,
)
@@ -116,7 +116,7 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
# With auth shows four
response = canned_write_client.get(
"/data.json",
- cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")},
+ cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
)
assert 200 == response.status
assert [
@@ -132,6 +132,6 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
def test_canned_query_permissions(canned_write_client):
assert 403 == canned_write_client.get("/data/delete_name").status
assert 200 == canned_write_client.get("/data/update_name").status
- cookies = {"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")}
+ cookies = {"ds_actor": canned_write_client.actor_cookie({"id": "root"})}
assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status
assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 477b8160..1be9529a 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -21,7 +21,7 @@ def test_view_instance(allow, expected_anon, expected_auth):
# Should be no padlock
assert "Datasette 🔒
" not in anon_response.text
auth_response = client.get(
- path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ path, cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert expected_auth == auth_response.status
# Check for the padlock
@@ -48,7 +48,7 @@ def test_view_database(allow, expected_anon, expected_auth):
# Should be no padlock
assert ">fixtures 🔒" not in anon_response.text
auth_response = client.get(
- path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ path, cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert expected_auth == auth_response.status
if (
@@ -69,7 +69,7 @@ def test_database_list_respects_view_database():
assert 'data' in anon_response.text
assert 'fixtures' not in anon_response.text
auth_response = client.get(
- "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ "/", cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert 'data' in auth_response.text
assert 'fixtures 🔒' in auth_response.text
@@ -100,7 +100,7 @@ def test_database_list_respects_view_table():
for html_fragment in html_fragments:
assert html_fragment not in anon_response_text
auth_response_text = client.get(
- "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ "/", cookies={"ds_actor": client.actor_cookie({"id": "root"})},
).text
for html_fragment in html_fragments:
assert html_fragment in auth_response_text
@@ -127,7 +127,7 @@ def test_view_table(allow, expected_anon, expected_auth):
assert ">compound_three_primary_keys 🔒" not in anon_response.text
auth_response = client.get(
"/fixtures/compound_three_primary_keys",
- cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert expected_auth == auth_response.status
if allow and expected_anon == 403 and expected_auth == 200:
@@ -156,7 +156,7 @@ def test_table_list_respects_view_table():
for html_fragment in html_fragments:
assert html_fragment not in anon_response.text
auth_response = client.get(
- "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ "/fixtures", cookies={"ds_actor": client.actor_cookie({"id": "root"})}
)
for html_fragment in html_fragments:
assert html_fragment in auth_response.text
@@ -180,7 +180,7 @@ def test_view_query(allow, expected_anon, expected_auth):
# Should be no padlock
assert ">fixtures 🔒" not in anon_response.text
auth_response = client.get(
- "/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ "/fixtures/q", cookies={"ds_actor": client.actor_cookie({"id": "root"})}
)
assert expected_auth == auth_response.status
if allow and expected_anon == 403 and expected_auth == 200:
@@ -206,7 +206,7 @@ def test_execute_sql(metadata):
assert 403 == client.get("/fixtures/facet_cities?_where=id=3").status
# But for logged in user all of these should work:
- cookies = {"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ cookies = {"ds_actor": client.actor_cookie({"id": "root"})}
response_text = client.get("/fixtures", cookies=cookies).text
assert form_fragment in response_text
assert 200 == client.get("/fixtures?sql=select+1", cookies=cookies).status
@@ -231,7 +231,7 @@ def test_query_list_respects_view_query():
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")}
+ "/fixtures", cookies={"ds_actor": client.actor_cookie({"id": "root"})}
)
assert html_fragment in auth_response.text
@@ -290,7 +290,7 @@ def test_permissions_debug(app_client):
app_client.ds._permission_checks.clear()
assert 403 == app_client.get("/-/permissions").status
# With the cookie it should work
- cookie = app_client.ds.sign({"id": "root"}, "actor")
+ cookie = app_client.actor_cookie({"id": "root"})
response = app_client.get("/-/permissions", cookies={"ds_actor": cookie})
# Should show one failure and one success
soup = Soup(response.body, "html.parser")
From 9f236c4c00689a022fd1d508f2b809ee2305927f Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Wed, 10 Jun 2020 13:06:46 -0700
Subject: [PATCH 0113/1871] Warn that register_facet_classes may change, refs
#830
Also documented policy that plugin hooks should not be shipped without a real example. Refs #818
---
docs/contributing.rst | 1 +
docs/plugins.rst | 3 +++
2 files changed, 4 insertions(+)
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 6562afc8..ba52839c 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -12,6 +12,7 @@ General guidelines
* **master 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.
.. _devenvironment:
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 17fd64df..a28092a3 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -891,6 +891,9 @@ register_facet_classes()
Return a list of additional Facet subclasses to be registered.
+.. warning::
+ The design of this plugin hook is unstable and may change. See `issue 830 `__.
+
Each Facet subclass implements a new type of facet operation. The class should look like this:
.. code-block:: python
From 198545733b7a34d7b36ab6510ed30fb7687bcc7e Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Wed, 10 Jun 2020 16:56:53 -0700
Subject: [PATCH 0114/1871] Document that "allow": {} denies all
https://github.com/simonw/datasette/issues/831#issuecomment-642324847
---
docs/authentication.rst | 19 +++++++++++++++++++
tests/test_utils.py | 11 +++++++----
2 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 9b66132a..0da5a38b 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -94,6 +94,14 @@ This will match any actors with an ``"id"`` property of ``"root"`` - for example
"name": "Root User"
}
+An allow block can specify "no-one is allowed to do this" using an empty ``{}``:
+
+.. code-block:: json
+
+ {
+ "allow": {}
+ }
+
Allow keys can provide a list of values. These will match any actor that has any of those values.
.. code-block:: json
@@ -181,6 +189,17 @@ Here's how to restrict access to your entire Datasette instance to just the ``"i
}
}
+To deny access to all users, you can use ``"allow": {}``:
+
+.. code-block:: json
+
+ {
+ "title": "My entirely inaccessible instance",
+ "allow": {}
+ }
+
+One reason to do this is if you are using a Datasette plugin - such as `datasette-permissions-sql `__ - to control permissions instead.
+
.. _authentication_permissions_database:
Controlling access to specific databases
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0ffe8ae6..b490953f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -464,16 +464,19 @@ def test_multi_params(data, should_raise):
@pytest.mark.parametrize(
"actor,allow,expected",
[
+ # Default is to allow:
(None, None, True),
+ # {} means deny-all:
(None, {}, False),
- (None, {"id": "root"}, False),
- ({"id": "root"}, None, True),
({"id": "root"}, {}, False),
- ({"id": "simon", "staff": True}, {"staff": True}, True),
- ({"id": "simon", "staff": False}, {"staff": True}, False),
# Special case for "unauthenticated": true
(None, {"unauthenticated": True}, True),
(None, {"unauthenticated": False}, False),
+ # Match on just one property:
+ (None, {"id": "root"}, False),
+ ({"id": "root"}, None, True),
+ ({"id": "simon", "staff": True}, {"staff": True}, True),
+ ({"id": "simon", "staff": False}, {"staff": True}, False),
# Special "*" value for any key:
({"id": "root"}, {"id": "*"}, True),
({}, {"id": "*"}, False),
From ce4958018ede00fbdadf0c37a99889b6901bfb9b Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Wed, 10 Jun 2020 17:10:28 -0700
Subject: [PATCH 0115/1871] Clarify that view-query also lets you execute
writable queries
---
docs/authentication.rst | 2 +-
docs/sql_queries.rst | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 0da5a38b..6a526f34 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -461,7 +461,7 @@ Default *allow*.
view-query
----------
-Actor is allowed to view a :ref:`canned query ` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size
+Actor is allowed to view (and execute) a :ref:`canned query ` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
``resource`` - tuple: (string, string)
The name of the database, then the name of the canned query
diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst
index a73f6bc2..6cc32da1 100644
--- a/docs/sql_queries.rst
+++ b/docs/sql_queries.rst
@@ -223,7 +223,7 @@ Writable canned queries
Canned queries by default are read-only. You can use the ``"write": true`` key to indicate that a canned query can write to the database.
-See :ref:`authentication_permissions_metadata` for details on how to add permission checks to canned queries, using the ``"allow"`` key.
+See :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``"allow"`` key.
.. code-block:: json
From 371170eee8d1659437e42c8ee267cb4b2abcffb5 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 08:44:44 -0700
Subject: [PATCH 0116/1871] publish heroku now deploys with Python 3.8.3
---
datasette/publish/heroku.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/datasette/publish/heroku.py b/datasette/publish/heroku.py
index 4db81d8e..7adf9d92 100644
--- a/datasette/publish/heroku.py
+++ b/datasette/publish/heroku.py
@@ -167,7 +167,7 @@ def temporary_heroku_directory(
if metadata_content:
open("metadata.json", "w").write(json.dumps(metadata_content, indent=2))
- open("runtime.txt", "w").write("python-3.8.0")
+ open("runtime.txt", "w").write("python-3.8.3")
if branch:
install = [
From 98632f0a874b7b9dac6abf0abb9fdb7e2839a4d3 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 09:02:03 -0700
Subject: [PATCH 0117/1871] --secret command for datasette publish
Closes #787
---
datasette/cli.py | 28 +++++++++++++++---------
datasette/publish/cloudrun.py | 2 ++
datasette/publish/common.py | 7 ++++++
datasette/publish/heroku.py | 3 +++
datasette/utils/__init__.py | 7 +++++-
docs/datasette-package-help.txt | 3 +++
docs/datasette-publish-cloudrun-help.txt | 3 +++
docs/datasette-publish-heroku-help.txt | 3 +++
docs/plugins.rst | 1 +
tests/test_package.py | 8 ++++---
tests/test_publish_cloudrun.py | 3 +++
tests/test_utils.py | 4 ++++
12 files changed, 58 insertions(+), 14 deletions(-)
diff --git a/datasette/cli.py b/datasette/cli.py
index 2e3c8e36..ff9a2d5c 100644
--- a/datasette/cli.py
+++ b/datasette/cli.py
@@ -165,6 +165,12 @@ def plugins(all, plugins_dir):
)
@click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension")
@click.option("--version-note", help="Additional note to show on /-/versions")
+@click.option(
+ "--secret",
+ help="Secret used for signing secure values, such as signed cookies",
+ envvar="DATASETTE_PUBLISH_SECRET",
+ default=lambda: os.urandom(32).hex(),
+)
@click.option(
"-p", "--port", default=8001, help="Port to run the server on, defaults to 8001",
)
@@ -187,6 +193,7 @@ def package(
install,
spatialite,
version_note,
+ secret,
port,
**extra_metadata
):
@@ -203,16 +210,17 @@ def package(
with temporary_docker_directory(
files,
"datasette",
- metadata,
- extra_options,
- branch,
- template_dir,
- plugins_dir,
- static,
- install,
- spatialite,
- version_note,
- extra_metadata,
+ metadata=metadata,
+ extra_options=extra_options,
+ branch=branch,
+ template_dir=template_dir,
+ plugins_dir=plugins_dir,
+ static=static,
+ install=install,
+ spatialite=spatialite,
+ version_note=version_note,
+ secret=secret,
+ extra_metadata=extra_metadata,
port=port,
):
args = ["docker", "build"]
diff --git a/datasette/publish/cloudrun.py b/datasette/publish/cloudrun.py
index 8271209a..8f99dc2e 100644
--- a/datasette/publish/cloudrun.py
+++ b/datasette/publish/cloudrun.py
@@ -47,6 +47,7 @@ def publish_subcommand(publish):
install,
plugin_secret,
version_note,
+ secret,
title,
license,
license_url,
@@ -120,6 +121,7 @@ def publish_subcommand(publish):
install,
spatialite,
version_note,
+ secret,
extra_metadata,
environment_variables,
):
diff --git a/datasette/publish/common.py b/datasette/publish/common.py
index 2911029d..49a4798e 100644
--- a/datasette/publish/common.py
+++ b/datasette/publish/common.py
@@ -1,5 +1,6 @@
from ..utils import StaticMount
import click
+import os
import shutil
import sys
@@ -52,6 +53,12 @@ def add_common_publish_arguments_and_options(subcommand):
click.option(
"--version-note", help="Additional note to show on /-/versions"
),
+ click.option(
+ "--secret",
+ help="Secret used for signing secure values, such as signed cookies",
+ envvar="DATASETTE_PUBLISH_SECRET",
+ default=lambda: os.urandom(32).hex(),
+ ),
click.option("--title", help="Title for metadata"),
click.option("--license", help="License label for metadata"),
click.option("--license_url", help="License URL for metadata"),
diff --git a/datasette/publish/heroku.py b/datasette/publish/heroku.py
index 7adf9d92..6cda68da 100644
--- a/datasette/publish/heroku.py
+++ b/datasette/publish/heroku.py
@@ -35,6 +35,7 @@ def publish_subcommand(publish):
install,
plugin_secret,
version_note,
+ secret,
title,
license,
license_url,
@@ -100,6 +101,7 @@ def publish_subcommand(publish):
static,
install,
version_note,
+ secret,
extra_metadata,
):
app_name = None
@@ -144,6 +146,7 @@ def temporary_heroku_directory(
static,
install,
version_note,
+ secret,
extra_metadata=None,
):
extra_metadata = extra_metadata or {}
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 51373c46..5090f67e 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -278,10 +278,13 @@ def make_dockerfile(
install,
spatialite,
version_note,
+ secret,
environment_variables=None,
port=8001,
):
cmd = ["datasette", "serve", "--host", "0.0.0.0"]
+ environment_variables = environment_variables or {}
+ environment_variables["DATASETTE_SECRET"] = secret
for filename in files:
cmd.extend(["-i", filename])
cmd.extend(["--cors", "--inspect-file", "inspect-data.json"])
@@ -324,7 +327,7 @@ CMD {cmd}""".format(
environment_variables="\n".join(
[
"ENV {} '{}'".format(key, value)
- for key, value in (environment_variables or {}).items()
+ for key, value in environment_variables.items()
]
),
files=" ".join(files),
@@ -348,6 +351,7 @@ def temporary_docker_directory(
install,
spatialite,
version_note,
+ secret,
extra_metadata=None,
environment_variables=None,
port=8001,
@@ -381,6 +385,7 @@ def temporary_docker_directory(
install,
spatialite,
version_note,
+ secret,
environment_variables,
port=port,
)
diff --git a/docs/datasette-package-help.txt b/docs/datasette-package-help.txt
index 326b66cb..1b14f908 100644
--- a/docs/datasette-package-help.txt
+++ b/docs/datasette-package-help.txt
@@ -17,6 +17,9 @@ Options:
--install TEXT Additional packages (e.g. plugins) to install
--spatialite Enable SpatialLite extension
--version-note TEXT Additional note to show on /-/versions
+ --secret TEXT Secret used for signing secure values, such as signed
+ cookies
+
-p, --port INTEGER Port to run the server on, defaults to 8001
--title TEXT Title for metadata
--license TEXT License label for metadata
diff --git a/docs/datasette-publish-cloudrun-help.txt b/docs/datasette-publish-cloudrun-help.txt
index 98fc9c71..a625bd10 100644
--- a/docs/datasette-publish-cloudrun-help.txt
+++ b/docs/datasette-publish-cloudrun-help.txt
@@ -15,6 +15,9 @@ Options:
datasette-auth-github client_id xxx
--version-note TEXT Additional note to show on /-/versions
+ --secret TEXT Secret used for signing secure values, such as signed
+ cookies
+
--title TEXT Title for metadata
--license TEXT License label for metadata
--license_url TEXT License URL for metadata
diff --git a/docs/datasette-publish-heroku-help.txt b/docs/datasette-publish-heroku-help.txt
index ec157753..b2caa2cc 100644
--- a/docs/datasette-publish-heroku-help.txt
+++ b/docs/datasette-publish-heroku-help.txt
@@ -15,6 +15,9 @@ Options:
datasette-auth-github client_id xxx
--version-note TEXT Additional note to show on /-/versions
+ --secret TEXT Secret used for signing secure values, such as signed
+ cookies
+
--title TEXT Title for metadata
--license TEXT License label for metadata
--license_url TEXT License URL for metadata
diff --git a/docs/plugins.rst b/docs/plugins.rst
index a28092a3..989cf672 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -536,6 +536,7 @@ Let's say you want to build a plugin that adds a ``datasette publish my_hosting_
install,
plugin_secret,
version_note,
+ secret,
title,
license,
license_url,
diff --git a/tests/test_package.py b/tests/test_package.py
index f0cbe88f..3248b3a4 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -15,7 +15,7 @@ FROM python:3.8
COPY . /app
WORKDIR /app
-
+ENV DATASETTE_SECRET 'sekrit'
RUN pip install -U datasette
RUN datasette inspect test.db --inspect-file inspect-data.json
ENV PORT {port}
@@ -33,7 +33,7 @@ def test_package(mock_call, mock_which):
mock_call.side_effect = capture
with runner.isolated_filesystem():
open("test.db", "w").write("data")
- result = runner.invoke(cli.cli, ["package", "test.db"])
+ result = runner.invoke(cli.cli, ["package", "test.db", "--secret", "sekrit"])
assert 0 == result.exit_code
mock_call.assert_has_calls([mock.call(["docker", "build", "."])])
assert EXPECTED_DOCKERFILE.format(port=8001) == capture.captured
@@ -48,6 +48,8 @@ def test_package_with_port(mock_call, mock_which):
runner = CliRunner()
with runner.isolated_filesystem():
open("test.db", "w").write("data")
- result = runner.invoke(cli.cli, ["package", "test.db", "-p", "8080"])
+ result = runner.invoke(
+ cli.cli, ["package", "test.db", "-p", "8080", "--secret", "sekrit"]
+ )
assert 0 == result.exit_code
assert EXPECTED_DOCKERFILE.format(port=8080) == capture.captured
diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py
index 55c207c7..c3ed1f90 100644
--- a/tests/test_publish_cloudrun.py
+++ b/tests/test_publish_cloudrun.py
@@ -172,6 +172,8 @@ def test_publish_cloudrun_plugin_secrets(mock_call, mock_output, mock_which):
"client_id",
"x-client-id",
"--show-files",
+ "--secret",
+ "x-secret",
],
)
dockerfile = (
@@ -184,6 +186,7 @@ COPY . /app
WORKDIR /app
ENV DATASETTE_AUTH_GITHUB_CLIENT_ID 'x-client-id'
+ENV DATASETTE_SECRET 'x-secret'
RUN pip install -U datasette
RUN datasette inspect test.db --inspect-file inspect-data.json
ENV PORT 8001
diff --git a/tests/test_utils.py b/tests/test_utils.py
index b490953f..d613e999 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -247,6 +247,7 @@ def test_temporary_docker_directory_uses_hard_link():
install=[],
spatialite=False,
version_note=None,
+ secret="secret",
) as temp_docker:
hello = os.path.join(temp_docker, "hello")
assert "world" == open(hello).read()
@@ -274,6 +275,7 @@ def test_temporary_docker_directory_uses_copy_if_hard_link_fails(mock_link):
install=[],
spatialite=False,
version_note=None,
+ secret=None,
) as temp_docker:
hello = os.path.join(temp_docker, "hello")
assert "world" == open(hello).read()
@@ -297,11 +299,13 @@ def test_temporary_docker_directory_quotes_args():
install=[],
spatialite=False,
version_note="$PWD",
+ secret="secret",
) as temp_docker:
df = os.path.join(temp_docker, "Dockerfile")
df_contents = open(df).read()
assert "'$PWD'" in df_contents
assert "'--$HOME'" in df_contents
+ assert "ENV DATASETTE_SECRET 'secret'" in df_contents
def test_compound_keys_after_sql():
From fcc7cd6379ab62b5c2440d26935659a797133030 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 09:04:32 -0700
Subject: [PATCH 0118/1871] rST formatting
---
docs/publish.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/publish.rst b/docs/publish.rst
index c1024bd7..6eff74d0 100644
--- a/docs/publish.rst
+++ b/docs/publish.rst
@@ -139,7 +139,7 @@ You can now run the resulting container like so::
This exposes port 8001 inside the container as port 8081 on your host machine, so you can access the application at ``http://localhost:8081/``
-You can customize the port that is exposed by the countainer using the ``--port`` option:
+You can customize the port that is exposed by the countainer using the ``--port`` option::
datasette package mydatabase.db --port 8080
From 09bf3c63225babe8e28cde880ca4399ca7dbd78b Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 09:14:30 -0700
Subject: [PATCH 0119/1871] Documentation for publish --secret, refs #787
---
docs/config.rst | 13 +++++++++++++
docs/publish.rst | 2 ++
2 files changed, 15 insertions(+)
diff --git a/docs/config.rst b/docs/config.rst
index ab14ea7b..bbbea822 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -306,3 +306,16 @@ One way to generate a secure random secret is to use Python like this::
cdb19e94283a20f9d42cca50c5a4871c0aa07392db308755d60a1a5b9bb0fa52
Plugin authors make use of this signing mechanism in their plugins using :ref:`datasette_sign` and :ref:`datasette_unsign`.
+
+.. _config_publish_secrets:
+
+Using secrets with datasette publish
+------------------------------------
+
+The :ref:`cli_publish` and :ref:`cli_package` commands both generate a secret for you automatically when Datasette is deployed.
+
+This means that every time you deploy a new version of a Datasette project, a new secret will be generated. This will cause signed cookies to become inalid on every fresh deploy.
+
+You can fix this by creating a secret that will be used for multiple deploys and passing it using the ``--secret`` option::
+
+ datasette publish cloudrun mydb.db --service=my-service --secret=cdb19e94283a20f9d42cca5
diff --git a/docs/publish.rst b/docs/publish.rst
index 6eff74d0..ebaf826a 100644
--- a/docs/publish.rst
+++ b/docs/publish.rst
@@ -100,6 +100,8 @@ If a plugin has any :ref:`plugins_configuration_secret` you can use the ``--plug
--plugin-secret datasette-auth-github client_id your_client_id \
--plugin-secret datasette-auth-github client_secret your_client_secret
+.. _cli_package:
+
datasette package
=================
From 29c5ff493ad7918b8fc44ea7920b41530e56dd5d Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 15:14:51 -0700
Subject: [PATCH 0120/1871] view-instance permission for debug URLs, closes
#833
---
datasette/views/special.py | 8 ++++++--
tests/test_permissions.py | 30 ++++++++++++++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/datasette/views/special.py b/datasette/views/special.py
index dc6a25dc..6fcb6b5e 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -14,6 +14,7 @@ class JsonDataView(BaseView):
self.needs_request = needs_request
async def get(self, request, as_format):
+ await self.check_permission(request, "view-instance")
if self.needs_request:
data = self.data_callback(request)
else:
@@ -46,6 +47,7 @@ class PatternPortfolioView(BaseView):
self.ds = datasette
async def get(self, request):
+ await self.check_permission(request, "view-instance")
return await self.render(["patterns.html"], request=request)
@@ -77,8 +79,8 @@ class PermissionsDebugView(BaseView):
self.ds = datasette
async def get(self, request):
- if not await self.ds.permission_allowed(request.actor, "permissions-debug"):
- return Response("Permission denied", status=403)
+ await self.check_permission(request, "view-instance")
+ await self.check_permission(request, "permissions-debug")
return await self.render(
["permissions_debug.html"],
request,
@@ -93,9 +95,11 @@ class MessagesDebugView(BaseView):
self.ds = datasette
async def get(self, request):
+ await self.check_permission(request, "view-instance")
return await self.render(["messages_debug.html"], request)
async def post(self, request):
+ await self.check_permission(request, "view-instance")
post = await request.post_vars()
message = post.get("message", "")
message_type = post.get("message_type") or "INFO"
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 1be9529a..fcc1b5ed 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -316,3 +316,33 @@ def test_permissions_debug(app_client):
def test_allow_unauthenticated(allow, expected):
with make_app_client(metadata={"allow": allow}) as client:
assert expected == client.get("/").status
+
+
+@pytest.fixture(scope="session")
+def view_instance_client():
+ with make_app_client(metadata={"allow": {}}) as client:
+ yield client
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "/",
+ "/fixtures",
+ "/fixtures/facetable",
+ "/-/metadata",
+ "/-/versions",
+ "/-/plugins",
+ "/-/config",
+ "/-/threads",
+ "/-/databases",
+ "/-/actor",
+ "/-/permissions",
+ "/-/messages",
+ "/-/patterns",
+ ],
+)
+def test_view_instance(path, view_instance_client):
+ assert 403 == view_instance_client.get(path).status
+ if path not in ("/-/permissions", "/-/messages", "/-/patterns"):
+ assert 403 == view_instance_client.get(path + ".json").status
From f39f11133126158e28780dee91bb9c7719ef5875 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 15:47:19 -0700
Subject: [PATCH 0121/1871] Fixed actor_matches_allow bug, closes #836
---
datasette/utils/__init__.py | 2 +-
tests/test_utils.py | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 5090f67e..69cfa400 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -884,7 +884,7 @@ def actor_matches_allow(actor, allow):
values = [values]
actor_values = actor.get(key)
if actor_values is None:
- return False
+ continue
if not isinstance(actor_values, list):
actor_values = [actor_values]
actor_values = set(actor_values)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d613e999..da1d298b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -497,6 +497,8 @@ def test_multi_params(data, should_raise):
({"id": "garry", "roles": ["staff", "dev"]}, {"roles": ["dev", "otter"]}, True),
({"id": "garry", "roles": []}, {"roles": ["staff"]}, False),
({"id": "garry"}, {"roles": ["staff"]}, False),
+ # Any single matching key works:
+ ({"id": "root"}, {"bot_id": "my-bot", "id": ["root"]}, True),
],
)
def test_actor_matches_allow(actor, allow, expected):
From fba8ff6e76253af2b03749ed8dd6e28985a7fb8f Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 17:21:48 -0700
Subject: [PATCH 0122/1871] "$env": "X" mechanism now works with nested lists,
closes #837
---
datasette/app.py | 14 ++------------
datasette/utils/__init__.py | 16 ++++++++++++++++
docs/changelog.rst | 2 ++
tests/fixtures.py | 1 +
tests/test_plugins.py | 13 +++++++++++++
tests/test_utils.py | 14 ++++++++++++++
6 files changed, 48 insertions(+), 12 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 71fa9afb..ebab3bee 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -45,6 +45,7 @@ from .utils import (
format_bytes,
module_from_path,
parse_metadata,
+ resolve_env_secrets,
sqlite3,
to_css_class,
)
@@ -367,18 +368,7 @@ class Datasette:
return None
plugin_config = plugins.get(plugin_name)
# Resolve any $file and $env keys
- if isinstance(plugin_config, dict):
- # Create a copy so we don't mutate the version visible at /-/metadata.json
- plugin_config_copy = dict(plugin_config)
- for key, value in plugin_config_copy.items():
- if isinstance(value, dict):
- if list(value.keys()) == ["$env"]:
- plugin_config_copy[key] = os.environ.get(
- list(value.values())[0]
- )
- elif list(value.keys()) == ["$file"]:
- plugin_config_copy[key] = open(list(value.values())[0]).read()
- return plugin_config_copy
+ plugin_config = resolve_env_secrets(plugin_config, os.environ)
return plugin_config
def app_css_hash(self):
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 69cfa400..ae7bbdb5 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -904,3 +904,19 @@ async def check_visibility(datasette, actor, action, resource, default=True):
None, action, resource=resource, default=default,
)
return visible, private
+
+
+def resolve_env_secrets(config, environ):
+ 'Create copy that recursively replaces {"$env": "NAME"} with values from environ'
+ if isinstance(config, dict):
+ if list(config.keys()) == ["$env"]:
+ return environ.get(list(config.values())[0])
+ else:
+ return {
+ key: resolve_env_secrets(value, environ)
+ for key, value in config.items()
+ }
+ elif isinstance(config, list):
+ return [resolve_env_secrets(value, environ) for value in config]
+ else:
+ return config
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 911fb1b6..3a01d05e 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -94,6 +94,8 @@ Both flash messages and user authentication needed a way to sign values and set
Datasette will generate a secret automatically when it starts up, but to avoid resetting the secret (and hence invalidating any cookies) every time the server restarts you should set your own secret. You can pass a secret to Datasette using the new ``--secret`` option or with a ``DATASETTE_SECRET`` environment variable. See :ref:`config_secret` for more details.
+You can also set a secret when you deploy Datasette using ``datasette publish`` or ``datasette package`` - see :ref:`config_publish_secrets`.
+
Plugins can now sign value and verify their signatures using the :ref:`datasette.sign() ` and :ref:`datasette.unsign() ` methods.
CSRF protection
diff --git a/tests/fixtures.py b/tests/fixtures.py
index a846999b..907bf895 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -408,6 +408,7 @@ METADATA = {
"plugins": {
"name-of-plugin": {"depth": "root"},
"env-plugin": {"foo": {"$env": "FOO_ENV"}},
+ "env-plugin-list": [{"in_a_list": {"$env": "FOO_ENV"}}],
"file-plugin": {"foo": {"$file": TEMP_PLUGIN_SECRET_FILE}},
},
"databases": {
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index c7bb4859..0fae3740 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -173,6 +173,19 @@ def test_plugin_config_env(app_client):
del os.environ["FOO_ENV"]
+def test_plugin_config_env_from_list(app_client):
+ os.environ["FOO_ENV"] = "FROM_ENVIRONMENT"
+ assert [{"in_a_list": "FROM_ENVIRONMENT"}] == app_client.ds.plugin_config(
+ "env-plugin-list"
+ )
+ # Ensure secrets aren't visible in /-/metadata.json
+ metadata = app_client.get("/-/metadata.json")
+ assert [{"in_a_list": {"$env": "FOO_ENV"}}] == metadata.json["plugins"][
+ "env-plugin-list"
+ ]
+ del os.environ["FOO_ENV"]
+
+
def test_plugin_config_file(app_client):
open(TEMP_PLUGIN_SECRET_FILE, "w").write("FROM_FILE")
assert {"foo": "FROM_FILE"} == app_client.ds.plugin_config("file-plugin")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index da1d298b..80c6f223 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -503,3 +503,17 @@ def test_multi_params(data, should_raise):
)
def test_actor_matches_allow(actor, allow, expected):
assert expected == utils.actor_matches_allow(actor, allow)
+
+
+@pytest.mark.parametrize(
+ "config,expected",
+ [
+ ({"foo": "bar"}, {"foo": "bar"}),
+ ({"$env": "FOO"}, "x"),
+ ({"k": {"$env": "FOO"}}, {"k": "x"}),
+ ([{"k": {"$env": "FOO"}}, {"z": {"$env": "FOO"}}], [{"k": "x"}, {"z": "x"}]),
+ ({"k": [{"in_a_list": {"$env": "FOO"}}]}, {"k": [{"in_a_list": "x"}]}),
+ ],
+)
+def test_resolve_env_secrets(config, expected):
+ assert expected == utils.resolve_env_secrets(config, {"FOO": "x"})
From 308bcc8805236b8eb5a08d8045c84f68bd0ddf0e Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 17:25:12 -0700
Subject: [PATCH 0123/1871] Fixed test_permissions_debug
---
datasette/views/special.py | 3 ++-
tests/test_permissions.py | 2 ++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 6fcb6b5e..6c378995 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -80,7 +80,8 @@ class PermissionsDebugView(BaseView):
async def get(self, request):
await self.check_permission(request, "view-instance")
- await self.check_permission(request, "permissions-debug")
+ if not await self.ds.permission_allowed(request.actor, "permissions-debug"):
+ return Response("Permission denied", status=403)
return await self.render(
["permissions_debug.html"],
request,
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index fcc1b5ed..241dd2e5 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -305,7 +305,9 @@ def test_permissions_debug(app_client):
]
assert [
{"action": "permissions-debug", "result": True, "used_default": False},
+ {"action": "view-instance", "result": True, "used_default": True},
{"action": "permissions-debug", "result": False, "used_default": True},
+ {"action": "view-instance", "result": True, "used_default": True},
] == checks
From 1d2e8e09a00a4b695317627483f352464ea8a105 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 17:33:16 -0700
Subject: [PATCH 0124/1871] Some last touches to the 0.44 release notes, refs
#806
---
docs/changelog.rst | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 3a01d05e..aca8f8c2 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -107,6 +107,13 @@ Since writable canned queries are built using POST forms, Datasette now ships wi
+Cookie methods
+~~~~~~~~~~~~~~
+
+Plugins can now use the new :ref:`response.set_cookie() ` method to set cookies.
+
+A new ``request.cookies`` method on the :ref:internals_request` can be used to read incoming cookies.
+
register_routes() plugin hooks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -134,6 +141,9 @@ Smaller changes
- Fixed broken CSS on nested 404 pages. (`#777 `__)
- New ``request.url_vars`` property. (`#822 `__)
- Fixed a bug with the ``python tests/fixtures.py`` command for outputting Datasette's testing fixtures database and plugins. (`#804 `__)
+- ``datasette publish heroku`` now deploys using Python 3.8.3.
+- Added a warning that the :ref:`plugin_register_facet_classes` hook is unstable and may change in the future. (`#830 `__)
+- The ``{"$env": "ENVIRONMENT_VARIBALE"}`` mechanism (see :ref:`plugins_configuration_secret`) now works with variables inside nested lists. (`#837 `__)
The road to Datasette 1.0
~~~~~~~~~~~~~~~~~~~~~~~~~
From 793a52b31771280a6c8660efb9e48b9b763477ff Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 17:43:51 -0700
Subject: [PATCH 0125/1871] Link to datasett-auth-tokens and
datasette-permissions-sql in docs, refs #806
---
docs/authentication.rst | 4 ++--
docs/changelog.rst | 4 ++--
docs/ecosystem.rst | 10 ++++++++++
docs/internals.rst | 2 +-
docs/plugins.rst | 17 ++++++++++-------
5 files changed, 25 insertions(+), 12 deletions(-)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 6a526f34..2a6fa9bc 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -19,7 +19,7 @@ Every request to Datasette has an associated actor value, available in the code
The actor dictionary can be any shape - the design of that data structure is left up to the plugins. A useful convention is to include an ``"id"`` string, as demonstrated by the "root" actor below.
-Plugins can use the :ref:`plugin_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request.
+Plugins can use the :ref:`plugin_hook_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request.
.. _authentication_root:
@@ -314,7 +314,7 @@ Checking permissions in plugins
Datasette plugins can check if an actor has permission to perform an action using the :ref:`datasette.permission_allowed(...)` method.
-Datasette core performs a number of permission checks, :ref:`documented below `. Plugins can implement the :ref:`plugin_permission_allowed` plugin hook to participate in decisions about whether an actor should be able to perform a specified action.
+Datasette core performs a number of permission checks, :ref:`documented below `. Plugins can implement the :ref:`plugin_hook_permission_allowed` plugin hook to participate in decisions about whether an actor should be able to perform a specified action.
.. _authentication_actor_matches_allow:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index aca8f8c2..3a7f9562 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -27,7 +27,7 @@ You'll need to install plugins if you want full user accounts, but default Datas
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)
-Plugins can implement new ways of authenticating users using the new :ref:`plugin_actor_from_request` hook.
+Plugins can implement new ways of authenticating users using the new :ref:`plugin_hook_actor_from_request` hook.
Permissions
~~~~~~~~~~~
@@ -52,7 +52,7 @@ You can use the new ``"allow"`` block syntax in ``metadata.json`` (or ``metadata
See :ref:`authentication_permissions_allow` for more details.
-Plugins can implement their own custom permission checks using the new :ref:`plugin_permission_allowed` hook.
+Plugins can implement their own custom permission checks using the new :ref:`plugin_hook_permission_allowed` hook.
A new debug page at ``/-/permissions`` shows recent permission checks, to help administrators and plugin authors understand exactly what checks are being performed. This tool defaults to only being available to the root user, but can be exposed to other users by plugins that respond to the ``permissions-debug`` permission. (`#788 `__)
diff --git a/docs/ecosystem.rst b/docs/ecosystem.rst
index 4777cc16..dcb5a887 100644
--- a/docs/ecosystem.rst
+++ b/docs/ecosystem.rst
@@ -87,6 +87,16 @@ datasette-auth-github
`datasette-auth-github `__ adds an authentication layer to Datasette. Users will have to sign in using their GitHub account before they can view data or interact with Datasette. You can also use it to restrict access to specific GitHub users, or to members of specified GitHub `organizations `__ or `teams `__.
+datasette-auth-tokens
+---------------------
+
+`datasette-auth-tokens `__ provides a mechanism for creating secret API tokens that can then be used with Datasette's :ref:`authentication` system.
+
+datasette-permissions-sql
+---------------------
+
+`datasette-permissions-sql `__ lets you configure Datasette permissions checks to use custom SQL queries, which means you can make permisison decisions based on data contained within your databases.
+
datasette-upload-csvs
---------------------
diff --git a/docs/internals.rst b/docs/internals.rst
index d75544e1..ab9da410 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -219,7 +219,7 @@ await .permission_allowed(actor, action, resource=None, default=False)
Check if the given actor has :ref:`permission ` to perform the given action on the given resource.
-Some permission checks are carried out against :ref:`rules defined in metadata.json `, while other custom permissions may be decided by plugins that implement the :ref:`plugin_permission_allowed` plugin hook.
+Some permission checks are carried out against :ref:`rules defined in metadata.json `, while other custom permissions may be decided by plugins that implement the :ref:`plugin_hook_permission_allowed` plugin hook.
If neither ``metadata.json`` nor any of the plugins provide an answer to the permission query the ``default`` argument will be returned.
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 989cf672..608f93da 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -25,9 +25,8 @@ Things you can do with plugins include:
* Customize how database values are rendered in the Datasette interface, for example
`datasette-render-binary `__ and
`datasette-pretty-json `__.
-* Wrap the entire Datasette application in custom ASGI middleware to add new pages
- or implement authentication, for example
- `datasette-auth-github `__.
+* Customize how Datasette's authentication and permissions systems work, for example `datasette-auth-tokens `__ and
+ `datasette-permissions-sql `__.
.. _plugins_installing:
@@ -996,7 +995,7 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att
Examples: `datasette-auth-github `_, `datasette-search-all `_, `datasette-media `_
-.. _plugin_actor_from_request:
+.. _plugin_hook_actor_from_request:
actor_from_request(datasette, request)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1055,7 +1054,9 @@ Instead of returning a dictionary, this function can return an awaitable functio
return inner
-.. _plugin_permission_allowed:
+Example: `datasette-auth-tokens `_
+
+.. _plugin_hook_permission_allowed:
permission_allowed(datasette, actor, action, resource)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1064,7 +1065,7 @@ permission_allowed(datasette, actor, action, resource)
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
``actor`` - dictionary
- The current actor, as decided by :ref:`plugin_actor_from_request`.
+ The current actor, as decided by :ref:`plugin_hook_actor_from_request`.
``action`` - string
The action to be performed, e.g. ``"edit-table"``.
@@ -1110,4 +1111,6 @@ Here's an example that allows users to view the ``admin_log`` table only if thei
return inner
-See :ref:`permissions` for a full list of permissions that are included in Datasette core.
+See :ref:`built-in permissions ` for a full list of permissions that are included in Datasette core.
+
+Example: `datasette-permissions-sql `_
From 9ae0d483ead93c0832142e5dc85959ae3c8f73ea Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 17:48:20 -0700
Subject: [PATCH 0126/1871] Get "$file": "../path" mechanism working again,
closes #839
---
datasette/utils/__init__.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index ae7bbdb5..14060669 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -911,6 +911,8 @@ def resolve_env_secrets(config, environ):
if isinstance(config, dict):
if list(config.keys()) == ["$env"]:
return environ.get(list(config.values())[0])
+ elif list(config.keys()) == ["$file"]:
+ return open(list(config.values())[0]).read()
else:
return {
key: resolve_env_secrets(value, environ)
From b906030235efbdff536405d66078f4868ce0d3bd Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 11 Jun 2020 18:19:30 -0700
Subject: [PATCH 0127/1871] Release Datasette 0.44
Refs #395, #519, #576, #699, #706, #774, #777, #781, #784, #788, #790, #797,
#798, #800, #802, #804, #819, #822, #825, #826, #827, #828, #829, #830,
#833, #836, #837, #839
Closes #806.
---
README.md | 1 +
docs/changelog.rst | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 90df75de..925d68d2 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Datasette is aimed at data journalists, museum curators, archivists, local gover
## News
+ * 11th June 2020: [Datasette 0.44](http://datasette.readthedocs.io/en/latest/changelog.html#v0-44) - [Authentication and permissions](https://datasette.readthedocs.io/en/latest/authentication.html), [writable canned queries](https://datasette.readthedocs.io/en/latest/sql_queries.html#writable-canned-queries), flash messages, new plugin hooks and much, much more.
* 28th May 2020: [Datasette 0.43](http://datasette.readthedocs.io/en/latest/changelog.html#v0-43) - Redesigned [register_output_renderer](https://datasette.readthedocs.io/en/latest/plugins.html#plugin-register-output-renderer) plugin hook and various small improvements and fixes.
* 8th May 2020: [Datasette 0.42](http://datasette.readthedocs.io/en/latest/changelog.html#v0-42) - Documented internal methods for plugins to execute read queries against a database.
* 6th May 2020: [Datasette 0.41](http://datasette.readthedocs.io/en/latest/changelog.html#v0-41) - New mechanism for [creating custom pages](https://datasette.readthedocs.io/en/0.41/custom_templates.html#custom-pages), new [configuration directory mode](https://datasette.readthedocs.io/en/0.41/config.html#configuration-directory-mode), new `?column__notlike=` table filter and various other smaller improvements.
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 3a7f9562..b1e95bb7 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,7 +6,7 @@ Changelog
.. _v0_44:
-0.44 (2020-06-??)
+0.44 (2020-06-11)
-----------------
Authentication and permissions, writable canned queries, flash messages, new plugin hooks and more.
From 09a3479a5402df96489ed6cab6cc9fd674bf3433 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 10:55:41 -0700
Subject: [PATCH 0128/1871] New "startup" plugin hook, closes #834
---
datasette/app.py | 7 +++++++
datasette/cli.py | 3 +++
datasette/hookspecs.py | 5 +++++
docs/plugins.rst | 33 +++++++++++++++++++++++++++++++++
tests/fixtures.py | 1 +
tests/plugins/my_plugin.py | 5 +++++
tests/test_cli.py | 1 +
tests/test_plugins.py | 6 ++++++
8 files changed, 61 insertions(+)
diff --git a/datasette/app.py b/datasette/app.py
index ebab3bee..ca2efa91 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -302,6 +302,13 @@ class Datasette:
self._permission_checks = collections.deque(maxlen=200)
self._root_token = secrets.token_hex(32)
+ async def invoke_startup(self):
+ for hook in pm.hook.startup(datasette=self):
+ if callable(hook):
+ hook = hook()
+ if asyncio.iscoroutine(hook):
+ hook = await hook
+
def sign(self, value, namespace="default"):
return URLSafeSerializer(self._secret, namespace).dumps(value)
diff --git a/datasette/cli.py b/datasette/cli.py
index ff9a2d5c..bba72484 100644
--- a/datasette/cli.py
+++ b/datasette/cli.py
@@ -397,6 +397,9 @@ def serve(
# Private utility mechanism for writing unit tests
return ds
+ # Run the "startup" plugin hooks
+ asyncio.get_event_loop().run_until_complete(ds.invoke_startup())
+
# Run async sanity checks - but only if we're not under pytest
asyncio.get_event_loop().run_until_complete(check_databases(ds))
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index ab3e131c..9fceee41 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -5,6 +5,11 @@ hookspec = HookspecMarker("datasette")
hookimpl = HookimplMarker("datasette")
+@hookspec
+def startup(datasette):
+ "Fires directly after Datasette first starts running"
+
+
@hookspec
def asgi_wrapper(datasette):
"Returns an ASGI middleware callable to wrap our ASGI application with"
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 608f93da..289be649 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -995,6 +995,39 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att
Examples: `datasette-auth-github `_, `datasette-search-all `_, `datasette-media `_
+.. _plugin_hook_startup:
+
+startup(datasette)
+~~~~~~~~~~~~~~~~~~
+
+This hook fires when the Datasette application server first starts up. You can implement a regular function, for example to validate required plugin configuration:
+
+.. code-block:: python
+
+ @hookimpl
+ def startup(datasette):
+ config = datasette.plugin_config("my-plugin") or {}
+ assert "required-setting" in config, "my-plugin requires setting required-setting"
+
+Or you can return an async function which will be awaited on startup. Use this option if you need to make any database queries:
+
+ @hookimpl
+ def startup(datasette):
+ async def inner():
+ db = datasette.get_database()
+ if "my_table" not in await db.table_names():
+ await db.execute_write("""
+ create table my_table (mycol text)
+ """, block=True)
+ return inner
+
+
+Potential use-cases:
+
+* Run some initialization code for the plugin
+* Create database tables that a plugin needs
+* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid
+
.. _plugin_hook_actor_from_request:
actor_from_request(datasette, request)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 907bf895..09819575 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -49,6 +49,7 @@ EXPECTED_PLUGINS = [
"register_facet_classes",
"register_routes",
"render_cell",
+ "startup",
],
},
{
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index a0f7441b..3f019a84 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -167,3 +167,8 @@ def register_routes():
(r"/two/(?P.*)$", two),
(r"/three/$", three),
]
+
+
+@hookimpl
+def startup(datasette):
+ datasette._startup_hook_fired = True
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 6939fe57..90aa990d 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -10,6 +10,7 @@ from click.testing import CliRunner
import io
import json
import pathlib
+import pytest
import textwrap
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index 0fae3740..c0a7438f 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -572,3 +572,9 @@ def test_register_routes_asgi(app_client):
response = app_client.get("/three/")
assert {"hello": "world"} == response.json
assert "1" == response.headers["x-three"]
+
+
+@pytest.mark.asyncio
+async def test_startup(app_client):
+ await app_client.ds.invoke_startup()
+ assert app_client.ds._startup_hook_fired
From 72ae975156a09619a808cdd03fddddcf62e6f533 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 10:58:32 -0700
Subject: [PATCH 0129/1871] Added test for async startup hook, refs #834
---
tests/plugins/my_plugin_2.py | 8 ++++++++
tests/test_plugins.py | 1 +
2 files changed, 9 insertions(+)
diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py
index 039112f4..bdfaea8d 100644
--- a/tests/plugins/my_plugin_2.py
+++ b/tests/plugins/my_plugin_2.py
@@ -120,3 +120,11 @@ def permission_allowed(datasette, actor, action):
return False
return inner
+
+
+@hookimpl
+def startup(datasette):
+ async def inner():
+ result = await datasette.get_database().execute("select 1 + 1")
+ datasette._startup_hook_calculation = result.first()[0]
+ return inner
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index c0a7438f..bc759385 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -578,3 +578,4 @@ def test_register_routes_asgi(app_client):
async def test_startup(app_client):
await app_client.ds.invoke_startup()
assert app_client.ds._startup_hook_fired
+ assert 2 == app_client.ds._startup_hook_calculation
From ae99af25361c9248c721153922c623bd5f440159 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 10:59:35 -0700
Subject: [PATCH 0130/1871] Fixed rST code formatting, refs #834
---
docs/plugins.rst | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 289be649..8add7352 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1011,6 +1011,8 @@ This hook fires when the Datasette application server first starts up. You can i
Or you can return an async function which will be awaited on startup. Use this option if you need to make any database queries:
+.. code-block:: python
+
@hookimpl
def startup(datasette):
async def inner():
@@ -1021,7 +1023,6 @@ Or you can return an async function which will be awaited on startup. Use this o
""", block=True)
return inner
-
Potential use-cases:
* Run some initialization code for the plugin
From d60bd6ad13ef908d7e66a677caee20536f3fb277 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 11:15:33 -0700
Subject: [PATCH 0131/1871] Update plugin tests, refs #834
---
tests/fixtures.py | 1 +
tests/plugins/my_plugin_2.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 09819575..e2f90f09 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -64,6 +64,7 @@ EXPECTED_PLUGINS = [
"extra_template_vars",
"permission_allowed",
"render_cell",
+ "startup",
],
},
{
diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py
index bdfaea8d..f4a082a0 100644
--- a/tests/plugins/my_plugin_2.py
+++ b/tests/plugins/my_plugin_2.py
@@ -127,4 +127,5 @@ def startup(datasette):
async def inner():
result = await datasette.get_database().execute("select 1 + 1")
datasette._startup_hook_calculation = result.first()[0]
+
return inner
From 0e49842e227a0f1f69d48108c87d17fe0379e548 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 11:29:14 -0700
Subject: [PATCH 0132/1871] datasette/actor_auth_cookie.py coverae to 100%,
refs #841
---
tests/test_auth.py | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 5e847445..bb4bee4b 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -26,6 +26,17 @@ def test_actor_cookie(app_client):
assert {"id": "test"} == app_client.ds._last_request.scope["actor"]
+def test_actor_cookie_invalid(app_client):
+ cookie = app_client.actor_cookie({"id": "test"})
+ # Break the signature
+ response = app_client.get("/", cookies={"ds_actor": cookie[:-1] + "."})
+ assert None == app_client.ds._last_request.scope["actor"]
+ # Break the cookie format
+ cookie = app_client.ds.sign({"b": {"id": "test"}}, "actor")
+ response = app_client.get("/", cookies={"ds_actor": cookie})
+ assert None == app_client.ds._last_request.scope["actor"]
+
+
@pytest.mark.parametrize(
"offset,expected", [((24 * 60 * 60), {"id": "test"}), (-(24 * 60 * 60), None),]
)
From 80c18a18fc444b89cc12b73599d56e091f3a3c87 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 13:48:23 -0700
Subject: [PATCH 0133/1871] Configure code coverage, refs #841, #843
---
.coveragerc | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 .coveragerc
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..6ca0fac8
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,2 @@
+[run]
+omit = datasette/_version.py, datasette/utils/shutil_backport.py
From cf7a2bdb404734910ec07abc7571351a2d934828 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 14:36:49 -0700
Subject: [PATCH 0134/1871] Action to run tests and upload coverage to
codecov.io
Closes #843.
---
.github/workflows/test-coverage.yml | 41 +++++++++++++++++++++++++++++
1 file changed, 41 insertions(+)
create mode 100644 .github/workflows/test-coverage.yml
diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml
new file mode 100644
index 00000000..99c0526a
--- /dev/null
+++ b/.github/workflows/test-coverage.yml
@@ -0,0 +1,41 @@
+name: Calculate test coverage
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out datasette
+ uses: actions/checkout@v2
+ - name: Set up Python
+ uses: actions/setup-python@v1
+ with:
+ python-version: 3.8
+ - uses: actions/cache@v2
+ name: Configure pip caching
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ - name: Install Python dependencies
+ run: |
+ python -m pip install -e .[test]
+ python -m pip install pytest-cov
+ - name: Run tests
+ run: |-
+ ls -lah
+ cat .coveragerc
+ pytest --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term
+ ls -lah
+ - name: Upload coverage report
+ uses: codecov/codecov-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ file: coverage.xml
From 0c27f10f9d2124f0f534c25612b58be20441c9d8 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 16:41:26 -0700
Subject: [PATCH 0135/1871] Updated plugin examples to include datasette-psutil
---
docs/plugins.rst | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 8add7352..113e6b24 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -884,6 +884,8 @@ The optional view function arguments are as follows:
The function can either return a :ref:`internals_response` or it can return nothing and instead respond directly to the request using the ASGI ``send`` function (for advanced uses only).
+Examples: `datasette-auth-github `__, `datasette-psutil `__
+
.. _plugin_register_facet_classes:
register_facet_classes()
@@ -993,7 +995,7 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att
return add_x_databases_header
return wrap_with_databases_header
-Examples: `datasette-auth-github `_, `datasette-search-all `_, `datasette-media `_
+Examples: `datasette-search-all `_, `datasette-media `_
.. _plugin_hook_startup:
From a4ad5a504c161bc3b1caaa40b22e46d600f7d4fc Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 13 Jun 2020 17:26:02 -0700
Subject: [PATCH 0136/1871] Workaround for 'Too many open files' in test runs,
refs #846
---
tests/fixtures.py | 3 +++
tests/test_api.py | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index e2f90f09..a4a96919 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -268,6 +268,9 @@ def make_app_client(
"default_page_size": 50,
"max_returned_rows": max_returned_rows or 100,
"sql_time_limit_ms": sql_time_limit_ms or 200,
+ # Default is 3 but this results in "too many open files"
+ # errors when running the full test suite:
+ "num_sql_threads": 1,
}
)
ds = Datasette(
diff --git a/tests/test_api.py b/tests/test_api.py
index 1a54edec..322a0001 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1320,7 +1320,7 @@ def test_config_json(app_client):
"suggest_facets": True,
"default_cache_ttl": 5,
"default_cache_ttl_hashed": 365 * 24 * 60 * 60,
- "num_sql_threads": 3,
+ "num_sql_threads": 1,
"cache_size_kb": 0,
"allow_csv_stream": True,
"max_csv_mb": 100,
From d2aef9f7ef30fa20b1450cd181cf803f44fb4e21 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 09:21:15 -0700
Subject: [PATCH 0137/1871] Test illustrating POST against register_routes(),
closes #853
---
tests/plugins/my_plugin.py | 7 +++++++
tests/test_plugins.py | 7 +++++++
2 files changed, 14 insertions(+)
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index 3f019a84..72736e84 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -162,10 +162,17 @@ def register_routes():
send, {"hello": "world"}, status=200, headers={"x-three": "1"}
)
+ async def post(request):
+ if request.method == "GET":
+ return Response.html(request.scope["csrftoken"]())
+ else:
+ return Response.json(await request.post_vars())
+
return [
(r"/one/$", one),
(r"/two/(?P.*)$", two),
(r"/three/$", three),
+ (r"/post/$", post),
]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index bc759385..e3a234f2 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -568,6 +568,13 @@ def test_register_routes(app_client, path, body):
assert body == response.text
+def test_register_routes_post(app_client):
+ response = app_client.post("/post/", {"this is": "post data"}, csrftoken_from=True)
+ assert 200 == response.status
+ assert "csrftoken" in response.json
+ assert "post data" == response.json["this is"]
+
+
def test_register_routes_asgi(app_client):
response = app_client.get("/three/")
assert {"hello": "world"} == response.json
From 6151c25a5a8d566c109af296244b9267c536bd9a Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 11:37:28 -0700
Subject: [PATCH 0138/1871] Respect existing scope["actor"] if set, closes #854
---
datasette/app.py | 3 ++-
tests/fixtures.py | 1 +
tests/plugins/my_plugin.py | 14 ++++++++++++++
tests/test_plugins.py | 5 +++++
4 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/datasette/app.py b/datasette/app.py
index ca2efa91..c684eabc 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -908,6 +908,7 @@ class DatasetteRouter(AsgiRouter):
):
scope_modifications["scheme"] = "https"
# Handle authentication
+ default_actor = scope.get("actor") or None
actor = None
for actor in pm.hook.actor_from_request(
datasette=self.ds, request=Request(scope, receive)
@@ -918,7 +919,7 @@ class DatasetteRouter(AsgiRouter):
actor = await actor
if actor:
break
- scope_modifications["actor"] = actor
+ scope_modifications["actor"] = actor or default_actor
return await super().route_path(
dict(scope, **scope_modifications), receive, send, path
)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index a4a96919..612bee99 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -39,6 +39,7 @@ EXPECTED_PLUGINS = [
"version": None,
"hooks": [
"actor_from_request",
+ "asgi_wrapper",
"extra_body_script",
"extra_css_urls",
"extra_js_urls",
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index 72736e84..a86e3cbf 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -137,6 +137,20 @@ def actor_from_request(datasette, request):
return None
+@hookimpl
+def asgi_wrapper():
+ def wrap(app):
+ async def maybe_set_actor_in_scope(scope, recieve, send):
+ if b"_actor_in_scope" in scope["query_string"]:
+ scope = dict(scope, actor={"id": "from-scope"})
+ print(scope)
+ await app(scope, recieve, send)
+
+ return maybe_set_actor_in_scope
+
+ return wrap
+
+
@hookimpl
def permission_allowed(actor, action):
if action == "this_is_allowed":
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index e3a234f2..245c60f7 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -534,6 +534,11 @@ def test_actor_from_request_async(app_client):
assert {"id": "bot2", "1+1": 2} == app_client.ds._last_request.scope["actor"]
+def test_existing_scope_actor_respected(app_client):
+ app_client.get("/?_actor_in_scope=1")
+ assert {"id": "from-scope"} == app_client.ds._last_request.scope["actor"]
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
"action,expected",
From 13216cb6bd715b3068b917bdeb1f1f24d159c34c Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 13:40:33 -0700
Subject: [PATCH 0139/1871] Don't push alpha/beta tagged releases to Docker Hub
Refs #807
---
.travis.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.travis.yml b/.travis.yml
index 5e328d7a..5aafe398 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -32,7 +32,7 @@ jobs:
branch: master
tags: true
- stage: publish docker image
- if: tag IS present
+ if: (tag IS present) AND NOT (tag =~ [ab])
python: 3.6
script:
# Build and release to Docker Hub
From c81f637d862a6b13ac4b07cef5a493b62e079c81 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 13:49:52 -0700
Subject: [PATCH 0140/1871] Documentation for alpha/beta release process, refs
#807
---
docs/contributing.rst | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/docs/contributing.rst b/docs/contributing.rst
index ba52839c..75c1c3b2 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -147,6 +147,8 @@ We increment ``minor`` for new features.
We increment ``patch`` for bugfix releass.
+:ref:`contributing_release_alpha_beta` may have an additional ``a0`` or ``b0`` prefix - the integer component will be incremented with each subsequent alpha or beta.
+
To release a new version, first create a commit that updates :ref:`the changelog ` with highlights of the new version. An example `commit can be seen here `__::
# Update changelog
@@ -180,3 +182,14 @@ Final steps once the release has deployed to https://pypi.org/project/datasette/
* Manually post the new release to GitHub releases: https://github.com/simonw/datasette/releases - you can convert the release notes to Markdown by copying and pasting the rendered HTML into this tool: https://euangoddard.github.io/clipboard2markdown/
* Manually kick off a build of the `stable` branch on Read The Docs: https://readthedocs.org/projects/datasette/builds/
+
+.. _contributing_release_alpha_beta:
+
+Alpha and beta releases
+-----------------------
+
+Alpha and beta releases are published to preview upcoming features that may not yet be stable - in particular to preview new plugin hooks.
+
+You are welcome to try these out, but please be aware that details may change before the final release.
+
+Please join `discussions on the issue tracker `__ to share your thoughts and experiences with on alpha and beta features that you try out.
From dda932d818b34ccab11730a76554f0a3748d8348 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 13:58:09 -0700
Subject: [PATCH 0141/1871] Release notes for 0.45a0
Refs #834 #846 #854 #807
---
docs/changelog.rst | 12 ++++++++++++
docs/contributing.rst | 4 ++--
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index b1e95bb7..705ba4d4 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,18 @@
Changelog
=========
+.. _v0_45 alpha:
+
+0.45a0 (2020-06-18)
+-------------------
+
+.. warning:: This is an **alpha** release. See :ref:`contributing_alpha_beta`.
+
+- New :ref:`plugin_hook_startup` plugin hook. (`#834 `__)
+- Workaround for "Too many open files" error in test runs. (`#846 `__)
+- Respect existing ``scope["actor"]`` if already set by ASGI middleware. (`#854 `__)
+- New process for shipping :ref:`contributing_alpha_beta`. (`#807 `__)
+
.. _v0_44:
0.44 (2020-06-11)
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 75c1c3b2..03af7644 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -147,7 +147,7 @@ We increment ``minor`` for new features.
We increment ``patch`` for bugfix releass.
-:ref:`contributing_release_alpha_beta` may have an additional ``a0`` or ``b0`` prefix - the integer component will be incremented with each subsequent alpha or beta.
+:ref:`contributing_alpha_beta` may have an additional ``a0`` or ``b0`` prefix - the integer component will be incremented with each subsequent alpha or beta.
To release a new version, first create a commit that updates :ref:`the changelog ` with highlights of the new version. An example `commit can be seen here `__::
@@ -183,7 +183,7 @@ Final steps once the release has deployed to https://pypi.org/project/datasette/
* Manually post the new release to GitHub releases: https://github.com/simonw/datasette/releases - you can convert the release notes to Markdown by copying and pasting the rendered HTML into this tool: https://euangoddard.github.io/clipboard2markdown/
* Manually kick off a build of the `stable` branch on Read The Docs: https://readthedocs.org/projects/datasette/builds/
-.. _contributing_release_alpha_beta:
+.. _contributing_alpha_beta:
Alpha and beta releases
-----------------------
From d2f387591bdda3949162e1802816be6ca1bb777a Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 14:01:36 -0700
Subject: [PATCH 0142/1871] Better rST label for alpha release, refs #807
---
docs/changelog.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 705ba4d4..e117663f 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,7 +4,7 @@
Changelog
=========
-.. _v0_45 alpha:
+.. _v0_45a0:
0.45a0 (2020-06-18)
-------------------
From 6c2634583627bfab750c115cb13850252821d637 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 16:22:33 -0700
Subject: [PATCH 0143/1871] New plugin hook: canned_queries(), refs #852
---
datasette/app.py | 26 +++++++----
datasette/default_permissions.py | 75 ++++++++++++++++----------------
datasette/hookspecs.py | 5 +++
datasette/views/database.py | 4 +-
datasette/views/table.py | 6 ++-
docs/plugins.rst | 67 ++++++++++++++++++++++++++++
tests/fixtures.py | 2 +
tests/plugins/my_plugin.py | 9 ++++
tests/plugins/my_plugin_2.py | 14 ++++++
tests/test_canned_write.py | 10 ++++-
tests/test_html.py | 2 +
tests/test_plugins.py | 31 +++++++++++++
12 files changed, 202 insertions(+), 49 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index c684eabc..e131ba46 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -387,18 +387,28 @@ class Datasette:
).hexdigest()[:6]
return self._app_css_hash
- def get_canned_queries(self, database_name):
+ async def get_canned_queries(self, database_name, actor):
queries = self.metadata("queries", database=database_name, fallback=False) or {}
- names = queries.keys()
- return [self.get_canned_query(database_name, name) for name in names]
+ for more_queries in pm.hook.canned_queries(
+ datasette=self, database=database_name, actor=actor,
+ ):
+ if callable(more_queries):
+ more_queries = more_queries()
+ if asyncio.iscoroutine(more_queries):
+ more_queries = await more_queries
+ queries.update(more_queries or {})
+ # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
+ for key in queries:
+ if not isinstance(queries[key], dict):
+ queries[key] = {"sql": queries[key]}
+ # Also make sure "name" is available:
+ queries[key]["name"] = key
+ return queries
- def get_canned_query(self, database_name, query_name):
- queries = self.metadata("queries", database=database_name, fallback=False) or {}
+ async def get_canned_query(self, database_name, query_name, actor):
+ queries = await self.get_canned_queries(database_name, actor)
query = queries.get(query_name)
if query:
- if not isinstance(query, dict):
- query = {"sql": query}
- query["name"] = query_name
return query
def update_with_inherited_metadata(self, metadata):
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index e750acbf..0929a17a 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -4,41 +4,42 @@ from datasette.utils import actor_matches_allow
@hookimpl(tryfirst=True)
def permission_allowed(datasette, actor, action, resource):
- if action == "permissions-debug":
- if actor and actor.get("id") == "root":
- return True
- elif action == "view-instance":
- allow = datasette.metadata("allow")
- if allow is not None:
+ async def inner():
+ if action == "permissions-debug":
+ if actor and actor.get("id") == "root":
+ return True
+ elif action == "view-instance":
+ allow = datasette.metadata("allow")
+ if allow is not None:
+ return actor_matches_allow(actor, allow)
+ elif action == "view-database":
+ database_allow = datasette.metadata("allow", database=resource)
+ if database_allow is None:
+ return True
+ return actor_matches_allow(actor, database_allow)
+ elif action == "view-table":
+ database, table = resource
+ 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
+ database, query_name = resource
+ query = await datasette.get_canned_query(database, query_name, actor)
+ assert query is not None
+ allow = query.get("allow")
+ if allow is None:
+ return True
return actor_matches_allow(actor, allow)
- elif action == "view-database":
- database_allow = datasette.metadata("allow", database=resource)
- if database_allow is None:
- return True
- return actor_matches_allow(actor, database_allow)
- elif action == "view-table":
- database, table = resource
- 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
- database, query_name = resource
- queries_metadata = datasette.metadata("queries", database=database)
- assert query_name in queries_metadata
- if isinstance(queries_metadata[query_name], str):
- return True
- allow = queries_metadata[query_name].get("allow")
- if allow is None:
- return True
- return actor_matches_allow(actor, allow)
- elif action == "execute-sql":
- # Use allow_sql block from database block, or from top-level
- database_allow_sql = datasette.metadata("allow_sql", database=resource)
- if database_allow_sql is None:
- database_allow_sql = datasette.metadata("allow_sql")
- if database_allow_sql is None:
- return True
- return actor_matches_allow(actor, database_allow_sql)
+ elif action == "execute-sql":
+ # Use allow_sql block from database block, or from top-level
+ database_allow_sql = datasette.metadata("allow_sql", database=resource)
+ if database_allow_sql is None:
+ database_allow_sql = datasette.metadata("allow_sql")
+ if database_allow_sql is None:
+ return True
+ return actor_matches_allow(actor, database_allow_sql)
+
+ return inner
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 9fceee41..91feb49b 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -78,3 +78,8 @@ def actor_from_request(datasette, request):
@hookspec
def permission_allowed(datasette, actor, action, resource):
"Check if actor is allowed to perfom this action - return True, False or None"
+
+
+@hookspec
+def canned_queries(datasette, database, actor):
+ "Return a dictonary of canned query definitions or an awaitable function that returns them"
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 4fab2cfb..ad28fb63 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -70,7 +70,9 @@ class DatabaseView(DataView):
tables.sort(key=lambda t: (t["hidden"], t["name"]))
canned_queries = []
- for query in self.ds.get_canned_queries(database):
+ for query in (
+ await self.ds.get_canned_queries(database, request.actor)
+ ).values():
visible, private = await check_visibility(
self.ds, request.actor, "view-query", (database, query["name"]),
)
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 91245293..1a55a495 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -223,7 +223,9 @@ class TableView(RowTableShared):
async def post(self, request, db_name, table_and_format):
# Handle POST to a canned query
- canned_query = self.ds.get_canned_query(db_name, table_and_format)
+ canned_query = await self.ds.get_canned_query(
+ db_name, table_and_format, request.actor
+ )
assert canned_query, "You may only POST to a canned query"
return await QueryView(self.ds).data(
request,
@@ -247,7 +249,7 @@ class TableView(RowTableShared):
_next=None,
_size=None,
):
- canned_query = self.ds.get_canned_query(database, table)
+ canned_query = await self.ds.get_canned_query(database, table, request.actor)
if canned_query:
return await QueryView(self.ds).data(
request,
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 113e6b24..8444516c 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1031,6 +1031,73 @@ Potential use-cases:
* Create database tables that a plugin needs
* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid
+.. _plugin_hook_canned_queries:
+
+canned_queries(datasette, database, actor)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``datasette`` - :ref:`internals_datasette`
+ You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
+
+``database`` - string
+ The name of the database.
+
+``actor`` - dictionary or None
+ The currently authenticated :ref:`authentication_actor`.
+
+Ues this hook to return a dictionary of additional :ref:`canned query ` definitions for the specified database. The return value should be the same shape as the JSON described in the :ref:`canned query ` documentation.
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def canned_queries(datasette, database):
+ if database == "mydb":
+ return {
+ "my_query": {
+ "sql": "select * from my_table where id > :min_id"
+ }
+ }
+
+The hook can alternatively return an awaitable function that returns a list. Here's an example that returns queries that have been stored in the ``saved_queries`` database table, if one exists:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def canned_queries(datasette, database):
+ async def inner():
+ db = datasette.get_database(database)
+ if await db.table_exists("saved_queries"):
+ results = await db.execute("select name, sql from saved_queries")
+ return {result["name"]: {
+ "sql": result["sql"]
+ } for result in results}
+ return inner
+
+The actor parameter can be used to include the currently authenticated actor in your decision. Here's an example that returns saved queries that were saved by that actor:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def canned_queries(datasette, database, actor):
+ async def inner():
+ db = datasette.get_database(database)
+ if actor is not None and await db.table_exists("saved_queries"):
+ results = await db.execute(
+ "select name, sql from saved_queries where actor_id = :id", {
+ "id": actor["id"]
+ }
+ )
+ return {result["name"]: {
+ "sql": result["sql"]
+ } for result in results}
+ return inner
+
.. _plugin_hook_actor_from_request:
actor_from_request(datasette, request)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 612bee99..9b28c283 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -40,6 +40,7 @@ EXPECTED_PLUGINS = [
"hooks": [
"actor_from_request",
"asgi_wrapper",
+ "canned_queries",
"extra_body_script",
"extra_css_urls",
"extra_js_urls",
@@ -61,6 +62,7 @@ EXPECTED_PLUGINS = [
"hooks": [
"actor_from_request",
"asgi_wrapper",
+ "canned_queries",
"extra_js_urls",
"extra_template_vars",
"permission_allowed",
diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py
index a86e3cbf..7ed26908 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -193,3 +193,12 @@ def register_routes():
@hookimpl
def startup(datasette):
datasette._startup_hook_fired = True
+
+
+@hookimpl
+def canned_queries(datasette, database, actor):
+ return {
+ "from_hook": "select 1, '{}' as actor_id".format(
+ actor["id"] if actor else "null"
+ )
+ }
diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py
index f4a082a0..556c8090 100644
--- a/tests/plugins/my_plugin_2.py
+++ b/tests/plugins/my_plugin_2.py
@@ -129,3 +129,17 @@ def startup(datasette):
datasette._startup_hook_calculation = result.first()[0]
return inner
+
+
+@hookimpl
+def canned_queries(datasette, database):
+ async def inner():
+ return {
+ "from_async_hook": "select {}".format(
+ (
+ await datasette.get_database(database).execute("select 1 + 1")
+ ).first()[0]
+ )
+ }
+
+ return inner
diff --git a/tests/test_canned_write.py b/tests/test_canned_write.py
index 4257806e..c36baa09 100644
--- a/tests/test_canned_write.py
+++ b/tests/test_canned_write.py
@@ -111,7 +111,13 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
query_names = [
q["name"] for q in canned_write_client.get("/data.json").json["queries"]
]
- assert ["add_name", "add_name_specify_id", "update_name"] == query_names
+ assert [
+ "add_name",
+ "add_name_specify_id",
+ "update_name",
+ "from_async_hook",
+ "from_hook",
+ ] == query_names
# With auth shows four
response = canned_write_client.get(
@@ -124,6 +130,8 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
{"name": "add_name_specify_id", "private": False},
{"name": "delete_name", "private": True},
{"name": "update_name", "private": False},
+ {"name": "from_async_hook", "private": False},
+ {"name": "from_hook", "private": False},
] == [
{"name": q["name"], "private": q["private"]} for q in response.json["queries"]
]
diff --git a/tests/test_html.py b/tests/test_html.py
index f9b18daa..7bc935b0 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -97,6 +97,8 @@ def test_database_page(app_client):
),
("/fixtures/pragma_cache_size", "pragma_cache_size"),
("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
+ ("/fixtures/from_async_hook", "from_async_hook"),
+ ("/fixtures/from_hook", "from_hook"),
] == [(a["href"], a.text) for a in queries_ul.find_all("a")]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index 245c60f7..4f44430e 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -591,3 +591,34 @@ async def test_startup(app_client):
await app_client.ds.invoke_startup()
assert app_client.ds._startup_hook_fired
assert 2 == app_client.ds._startup_hook_calculation
+
+
+def test_canned_queries(app_client):
+ queries = app_client.get("/fixtures.json").json["queries"]
+ queries_by_name = {q["name"]: q for q in queries}
+ assert {
+ "sql": "select 2",
+ "name": "from_async_hook",
+ "private": False,
+ } == queries_by_name["from_async_hook"]
+ assert {
+ "sql": "select 1, 'null' as actor_id",
+ "name": "from_hook",
+ "private": False,
+ } == queries_by_name["from_hook"]
+
+
+def test_canned_queries_non_async(app_client):
+ response = app_client.get("/fixtures/from_hook.json?_shape=array")
+ assert [{"1": 1, "actor_id": "null"}] == response.json
+
+
+def test_canned_queries_async(app_client):
+ response = app_client.get("/fixtures/from_async_hook.json?_shape=array")
+ assert [{"2": 2}] == response.json
+
+
+def test_canned_queries_actor(app_client):
+ assert [{"1": 1, "actor_id": "bot"}] == app_client.get(
+ "/fixtures/from_hook.json?_bot=1&_shape=array"
+ ).json
From 9216127ace8d80493f743a4ef4c469f83a3b81ce Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 16:39:43 -0700
Subject: [PATCH 0144/1871] Documentation tweak, refs #852
---
docs/plugins.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 8444516c..dce1bdf0 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1043,7 +1043,7 @@ canned_queries(datasette, database, actor)
The name of the database.
``actor`` - dictionary or None
- The currently authenticated :ref:`authentication_actor`.
+ The currently authenticated :ref:`actor `.
Ues this hook to return a dictionary of additional :ref:`canned query ` definitions for the specified database. The return value should be the same shape as the JSON described in the :ref:`canned query ` documentation.
From 0807c4200f6b31c804c476eb546ead3f875a2ecc Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 16:40:45 -0700
Subject: [PATCH 0145/1871] Release notes for 0.45a1, refs #852
---
docs/changelog.rst | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index e117663f..6f3af8ce 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,14 +4,15 @@
Changelog
=========
-.. _v0_45a0:
+.. _v0_45a1:
-0.45a0 (2020-06-18)
+0.45a1 (2020-06-18)
-------------------
.. warning:: This is an **alpha** release. See :ref:`contributing_alpha_beta`.
- New :ref:`plugin_hook_startup` plugin hook. (`#834 `__)
+- New :ref:`plugin_hook_canned_queries` plugin hook. (`#852 `__)
- Workaround for "Too many open files" error in test runs. (`#846 `__)
- Respect existing ``scope["actor"]`` if already set by ASGI middleware. (`#854 `__)
- New process for shipping :ref:`contributing_alpha_beta`. (`#807 `__)
From b59b92b1b0517cf18fa748ff9d0a0bf86298dd43 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 16:52:06 -0700
Subject: [PATCH 0146/1871] Fix for tests - order was inconsistent, refs #852
---
tests/test_canned_write.py | 20 ++++++++++++--------
tests/test_html.py | 8 +++++---
2 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/tests/test_canned_write.py b/tests/test_canned_write.py
index c36baa09..e33eed69 100644
--- a/tests/test_canned_write.py
+++ b/tests/test_canned_write.py
@@ -108,16 +108,16 @@ def test_vary_header(canned_write_client):
def test_canned_query_permissions_on_database_page(canned_write_client):
# Without auth only shows three queries
- query_names = [
+ query_names = {
q["name"] for q in canned_write_client.get("/data.json").json["queries"]
- ]
- assert [
+ }
+ assert {
"add_name",
"add_name_specify_id",
"update_name",
"from_async_hook",
"from_hook",
- ] == query_names
+ } == query_names
# With auth shows four
response = canned_write_client.get(
@@ -129,12 +129,16 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
{"name": "add_name", "private": False},
{"name": "add_name_specify_id", "private": False},
{"name": "delete_name", "private": True},
- {"name": "update_name", "private": False},
{"name": "from_async_hook", "private": False},
{"name": "from_hook", "private": False},
- ] == [
- {"name": q["name"], "private": q["private"]} for q in response.json["queries"]
- ]
+ {"name": "update_name", "private": False},
+ ] == sorted(
+ [
+ {"name": q["name"], "private": q["private"]}
+ for q in response.json["queries"]
+ ],
+ key=lambda q: q["name"],
+ )
def test_canned_query_permissions(canned_write_client):
diff --git a/tests/test_html.py b/tests/test_html.py
index 7bc935b0..1c7dce90 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -95,11 +95,13 @@ def test_database_page(app_client):
"/fixtures/%F0%9D%90%9C%F0%9D%90%A2%F0%9D%90%AD%F0%9D%90%A2%F0%9D%90%9E%F0%9D%90%AC",
"𝐜𝐢𝐭𝐢𝐞𝐬",
),
- ("/fixtures/pragma_cache_size", "pragma_cache_size"),
- ("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
("/fixtures/from_async_hook", "from_async_hook"),
("/fixtures/from_hook", "from_hook"),
- ] == [(a["href"], a.text) for a in queries_ul.find_all("a")]
+ ("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
+ ("/fixtures/pragma_cache_size", "pragma_cache_size"),
+ ] == sorted(
+ [(a["href"], a.text) for a in queries_ul.find_all("a")], key=lambda p: p[0]
+ )
def test_invalid_custom_sql(app_client):
From 64cc536b89b988b17e3ab853e4c64d9706543116 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Thu, 18 Jun 2020 17:03:23 -0700
Subject: [PATCH 0147/1871] Don't include prereleases in changelog badge
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 925d68d2..42eaaa81 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Datasette
[](https://pypi.org/project/datasette/)
-[](https://datasette.readthedocs.io/en/stable/changelog.html)
+[](https://datasette.readthedocs.io/en/stable/changelog.html)
[](https://pypi.org/project/datasette/)
[](https://travis-ci.org/simonw/datasette)
[](http://datasette.readthedocs.io/en/latest/?badge=latest)
From 55a6ffb93c57680e71a070416baae1129a0243b8 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Fri, 19 Jun 2020 20:08:30 -0700
Subject: [PATCH 0148/1871] Link to datasette-saved-queries plugin, closes #852
---
docs/changelog.rst | 2 +-
docs/ecosystem.rst | 6 +++++-
docs/plugins.rst | 6 +++++-
3 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 6f3af8ce..d580f03e 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -12,7 +12,7 @@ Changelog
.. warning:: This is an **alpha** release. See :ref:`contributing_alpha_beta`.
- New :ref:`plugin_hook_startup` plugin hook. (`#834 `__)
-- New :ref:`plugin_hook_canned_queries` plugin hook. (`#852 `__)
+- New :ref:`plugin_hook_canned_queries` plugin hook. See `datasette-saved-queries `__ for an example of this hook in action. (`#852 `__)
- Workaround for "Too many open files" error in test runs. (`#846 `__)
- Respect existing ``scope["actor"]`` if already set by ASGI middleware. (`#854 `__)
- New process for shipping :ref:`contributing_alpha_beta`. (`#807 `__)
diff --git a/docs/ecosystem.rst b/docs/ecosystem.rst
index dcb5a887..f2da885c 100644
--- a/docs/ecosystem.rst
+++ b/docs/ecosystem.rst
@@ -157,12 +157,16 @@ datasette-leaflet-geojson
`datasette-leaflet-geojson `__ looks out for columns containing GeoJSON formatted geographical information and displays them on a `Leaflet-powered `__ map.
-
datasette-pretty-json
---------------------
`datasette-pretty-json `__ seeks out JSON values in Datasette's table browsing interface and pretty-prints them, making them easier to read.
+datasette-saved-queries
+-----------------------
+
+`datasette-saved-queries `__ lets users interactively save queries to a ``saved_queries`` table. They are then made available as additional :ref:`canned queries `.
+
datasette-haversine
-------------------
diff --git a/docs/plugins.rst b/docs/plugins.rst
index dce1bdf0..d2743419 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -1028,9 +1028,11 @@ Or you can return an async function which will be awaited on startup. Use this o
Potential use-cases:
* Run some initialization code for the plugin
-* Create database tables that a plugin needs
+* Create database tables that a plugin needs on startup
* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid
+Example: `datasette-saved-queries `__
+
.. _plugin_hook_canned_queries:
canned_queries(datasette, database, actor)
@@ -1098,6 +1100,8 @@ The actor parameter can be used to include the currently authenticated actor in
} for result in results}
return inner
+Example: `datasette-saved-queries `__
+
.. _plugin_hook_actor_from_request:
actor_from_request(datasette, request)
From d1640ba76b8f10830c56d8289f476fefde3bd1fb Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 20 Jun 2020 08:48:39 -0700
Subject: [PATCH 0149/1871] Don't show prereleases on changelog badge
---
docs/index.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/index.rst b/docs/index.rst
index 5334386f..fa5d7f87 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -6,7 +6,7 @@ datasette|
.. |PyPI| image:: https://img.shields.io/pypi/v/datasette.svg
:target: https://pypi.org/project/datasette/
-.. |Changelog| image:: https://img.shields.io/github/v/release/simonw/datasette?include_prereleases&label=changelog
+.. |Changelog| image:: https://img.shields.io/github/v/release/simonw/datasette?label=changelog
:target: https://datasette.readthedocs.io/en/stable/changelog.html
.. |Python 3.x| image:: https://img.shields.io/pypi/pyversions/datasette.svg?logo=python&logoColor=white
:target: https://pypi.org/project/datasette/
From 84cbf1766083a785f5ce5154d0805654a5314d10 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sat, 20 Jun 2020 10:40:05 -0700
Subject: [PATCH 0150/1871] News: A cookiecutter template for writing Datasette
plugins
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 42eaaa81..84d1dcd4 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Datasette is aimed at data journalists, museum curators, archivists, local gover
## News
+ * 20th June 2020: [A cookiecutter template for writing Datasette plugins](https://simonwillison.net/2020/Jun/20/cookiecutter-plugins/)
* 11th June 2020: [Datasette 0.44](http://datasette.readthedocs.io/en/latest/changelog.html#v0-44) - [Authentication and permissions](https://datasette.readthedocs.io/en/latest/authentication.html), [writable canned queries](https://datasette.readthedocs.io/en/latest/sql_queries.html#writable-canned-queries), flash messages, new plugin hooks and much, much more.
* 28th May 2020: [Datasette 0.43](http://datasette.readthedocs.io/en/latest/changelog.html#v0-43) - Redesigned [register_output_renderer](https://datasette.readthedocs.io/en/latest/plugins.html#plugin-register-output-renderer) plugin hook and various small improvements and fixes.
* 8th May 2020: [Datasette 0.42](http://datasette.readthedocs.io/en/latest/changelog.html#v0-42) - Documented internal methods for plugins to execute read queries against a database.
From e4216ff5035f57f2fb66031f105e41c3b9728bc1 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 21 Jun 2020 14:55:17 -0700
Subject: [PATCH 0151/1871] Fixed rST warning
---
docs/ecosystem.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/ecosystem.rst b/docs/ecosystem.rst
index f2da885c..7c8959dd 100644
--- a/docs/ecosystem.rst
+++ b/docs/ecosystem.rst
@@ -93,7 +93,7 @@ datasette-auth-tokens
`datasette-auth-tokens `__ provides a mechanism for creating secret API tokens that can then be used with Datasette's :ref:`authentication` system.
datasette-permissions-sql
----------------------
+-------------------------
`datasette-permissions-sql `__ lets you configure Datasette permissions checks to use custom SQL queries, which means you can make permisison decisions based on data contained within your databases.
From 36e77e100632573e1cf907aba9462debac7928e9 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 21 Jun 2020 17:33:48 -0700
Subject: [PATCH 0152/1871] Move plugin hooks docs to plugin_hooks.rst, refs
#687
---
docs/index.rst | 1 +
docs/plugin_hooks.rst | 888 +++++++++++++++++++++++++++++++++++++++++
docs/plugins.rst | 889 ------------------------------------------
3 files changed, 889 insertions(+), 889 deletions(-)
create mode 100644 docs/plugin_hooks.rst
diff --git a/docs/index.rst b/docs/index.rst
index fa5d7f87..20a55b2c 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -51,6 +51,7 @@ Contents
introspection
custom_templates
plugins
+ plugin_hooks
internals
contributing
changelog
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
new file mode 100644
index 00000000..19f076b9
--- /dev/null
+++ b/docs/plugin_hooks.rst
@@ -0,0 +1,888 @@
+.. _plugin_hooks:
+
+Plugin hooks
+============
+
+When you implement a plugin hook you can accept any or all of the parameters that are documented as being passed to that hook. For example, you can implement a ``render_cell`` plugin hook like this even though the hook definition defines more parameters than just ``value`` and ``column``:
+
+.. code-block:: python
+
+ @hookimpl
+ def render_cell(value, column):
+ if column == "stars":
+ return "*" * int(value)
+
+The full list of available plugin hooks is as follows.
+
+.. _plugin_hook_prepare_connection:
+
+prepare_connection(conn, database, datasette)
+---------------------------------------------
+
+``conn`` - sqlite3 connection object
+ The connection that is being opened
+
+``database`` - string
+ The name of the database
+
+``datasette`` - :ref:`internals_datasette`
+ You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``
+
+This hook is called when a new SQLite database connection is created. You can
+use it to `register custom SQL functions `_,
+aggregates and collations. For example:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+ import random
+
+ @hookimpl
+ def prepare_connection(conn):
+ conn.create_function('random_integer', 2, random.randint)
+
+This registers a SQL function called ``random_integer`` which takes two
+arguments and can be called like this::
+
+ select random_integer(1, 10);
+
+Examples: `datasette-jellyfish `_, `datasette-jq `_, `datasette-haversine `__, `datasette-rure `__
+
+.. _plugin_hook_prepare_jinja2_environment:
+
+prepare_jinja2_environment(env)
+-------------------------------
+
+``env`` - jinja2 Environment
+ The template environment that is being prepared
+
+This hook is called with the Jinja2 environment that is used to evaluate
+Datasette HTML templates. You can use it to do things like `register custom
+template filters `_, for
+example:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def prepare_jinja2_environment(env):
+ env.filters['uppercase'] = lambda u: u.upper()
+
+You can now use this filter in your custom templates like so::
+
+ Table name: {{ table|uppercase }}
+
+.. _plugin_hook_extra_css_urls:
+
+extra_css_urls(template, database, table, datasette)
+----------------------------------------------------
+
+``template`` - string
+ The template that is being rendered, e.g. ``database.html``
+
+``database`` - string or None
+ The name of the database
+
+``table`` - string or None
+ The name of the table
+
+``datasette`` - :ref:`internals_datasette`
+ You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``
+
+Return a list of extra CSS URLs that should be included on the page. These can
+take advantage of the CSS class hooks described in :ref:`customization`.
+
+This can be a list of URLs:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def extra_css_urls():
+ return [
+ 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css'
+ ]
+
+Or a list of dictionaries defining both a URL and an
+`SRI hash `_:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def extra_css_urls():
+ return [{
+ 'url': 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css',
+ 'sri': 'sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4',
+ }]
+
+Examples: `datasette-cluster-map `_, `datasette-vega `_
+
+.. _plugin_hook_extra_js_urls:
+
+extra_js_urls(template, database, table, datasette)
+---------------------------------------------------
+
+Same arguments as ``extra_css_urls``.
+
+This works in the same way as ``extra_css_urls()`` but for JavaScript. You can
+return either a list of URLs or a list of dictionaries:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def extra_js_urls():
+ return [{
+ 'url': 'https://code.jquery.com/jquery-3.3.1.slim.min.js',
+ 'sri': 'sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo',
+ }]
+
+You can also return URLs to files from your plugin's ``static/`` directory, if
+you have one:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+
+ @hookimpl
+ def extra_js_urls():
+ return [
+ '/-/static-plugins/your-plugin/app.js'
+ ]
+
+Examples: `datasette-cluster-map `_, `datasette-vega `_
+
+.. _plugin_hook_publish_subcommand:
+
+publish_subcommand(publish)
+---------------------------
+
+``publish`` - Click publish command group
+ The Click command group for the ``datasette publish`` subcommand
+
+This hook allows you to create new providers for the ``datasette publish``
+command. Datasette uses this hook internally to implement the default ``now``
+and ``heroku`` subcommands, so you can read
+`their source `_
+to see examples of this hook in action.
+
+Let's say you want to build a plugin that adds a ``datasette publish my_hosting_provider --api_key=xxx mydatabase.db`` publish command. Your implementation would start like this:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+ from datasette.publish.common import add_common_publish_arguments_and_options
+ import click
+
+
+ @hookimpl
+ def publish_subcommand(publish):
+ @publish.command()
+ @add_common_publish_arguments_and_options
+ @click.option(
+ "-k",
+ "--api_key",
+ help="API key for talking to my hosting provider",
+ )
+ def my_hosting_provider(
+ files,
+ metadata,
+ extra_options,
+ branch,
+ template_dir,
+ plugins_dir,
+ static,
+ install,
+ plugin_secret,
+ version_note,
+ secret,
+ title,
+ license,
+ license_url,
+ source,
+ source_url,
+ about,
+ about_url,
+ api_key,
+ ):
+ # Your implementation goes here
+
+Examples: `datasette-publish-fly `_, `datasette-publish-now `_
+
+.. _plugin_hook_render_cell:
+
+render_cell(value, column, table, database, datasette)
+------------------------------------------------------
+
+Lets you customize the display of values within table cells in the HTML table view.
+
+``value`` - string, integer or None
+ The value that was loaded from the database
+
+``column`` - string
+ The name of the column being rendered
+
+``table`` - string or None
+ The name of the table - or ``None`` if this is a custom SQL query
+
+``database`` - string
+ The name of the database
+
+``datasette`` - :ref:`internals_datasette`
+ You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``
+
+If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value.
+
+If the hook returns a string, that string will be rendered in the table cell.
+
+If you want to return HTML markup you can do so by returning a ``jinja2.Markup`` object.
+
+Datasette will loop through all available ``render_cell`` hooks and display the value returned by the first one that does not return ``None``.
+
+Here is an example of a custom ``render_cell()`` plugin which looks for values that are a JSON string matching the following format::
+
+ {"href": "https://www.example.com/", "label": "Name"}
+
+If the value matches that pattern, the plugin returns an HTML link element:
+
+.. code-block:: python
+
+ from datasette import hookimpl
+ import jinja2
+ import json
+
+
+ @hookimpl
+ def render_cell(value):
+ # Render {"href": "...", "label": "..."} as link
+ if not isinstance(value, str):
+ return None
+ stripped = value.strip()
+ if not stripped.startswith("{") and stripped.endswith("}"):
+ return None
+ try:
+ data = json.loads(value)
+ except ValueError:
+ return None
+ if not isinstance(data, dict):
+ return None
+ if set(data.keys()) != {"href", "label"}:
+ return None
+ href = data["href"]
+ if not (
+ href.startswith("/") or href.startswith("http://")
+ or href.startswith("https://")
+ ):
+ return None
+ return jinja2.Markup('{label}'.format(
+ href=jinja2.escape(data["href"]),
+ label=jinja2.escape(data["label"] or "") or " "
+ ))
+
+Examples: `datasette-render-binary `_, `datasette-render-markdown `_
+
+.. _plugin_hook_extra_body_script:
+
+extra_body_script(template, database, table, view_name, datasette)
+------------------------------------------------------------------
+
+Extra JavaScript to be added to a ``")
json_data = r.search(app_client.get(path).text).group(1)
actual_data = json.loads(json_data)
assert expected_extra_body_script == actual_data
-def test_plugins_asgi_wrapper(app_client):
+def test_hook_asgi_wrapper(app_client):
response = app_client.get("/fixtures")
assert "fixtures" == response.headers["x-databases"]
-def test_plugins_extra_template_vars(restore_working_directory):
+def test_hook_extra_template_vars(restore_working_directory):
with make_app_client(
template_dir=str(pathlib.Path(__file__).parent / "test_templates")
) as client:
@@ -380,13 +380,13 @@ def test_view_names(view_names_client, path, view_name):
assert "view_name:{}".format(view_name) == response.text
-def test_register_output_renderer_no_parameters(app_client):
+def test_hook_register_output_renderer_no_parameters(app_client):
response = app_client.get("/fixtures/facetable.testnone")
assert 200 == response.status
assert b"Hello" == response.body
-def test_register_output_renderer_all_parameters(app_client):
+def test_hook_register_output_renderer_all_parameters(app_client):
response = app_client.get("/fixtures/facetable.testall")
assert 200 == response.status
# Lots of 'at 0x103a4a690' in here - replace those so we can do
@@ -436,19 +436,19 @@ def test_register_output_renderer_all_parameters(app_client):
assert "pragma_cache_size" == json.loads(query_response.body)["query_name"]
-def test_register_output_renderer_custom_status_code(app_client):
+def test_hook_register_output_renderer_custom_status_code(app_client):
response = app_client.get("/fixtures/pragma_cache_size.testall?status_code=202")
assert 202 == response.status
-def test_register_output_renderer_custom_content_type(app_client):
+def test_hook_register_output_renderer_custom_content_type(app_client):
response = app_client.get(
"/fixtures/pragma_cache_size.testall?content_type=text/blah"
)
assert "text/blah" == response.headers["content-type"]
-def test_register_output_renderer_custom_headers(app_client):
+def test_hook_register_output_renderer_custom_headers(app_client):
response = app_client.get(
"/fixtures/pragma_cache_size.testall?header=x-wow:1&header=x-gosh:2"
)
@@ -456,7 +456,7 @@ def test_register_output_renderer_custom_headers(app_client):
assert "2" == response.headers["x-gosh"]
-def test_register_output_renderer_can_render(app_client):
+def test_hook_register_output_renderer_can_render(app_client):
response = app_client.get("/fixtures/facetable?_no_can_render=1")
assert response.status == 200
links = (
@@ -492,7 +492,7 @@ def test_register_output_renderer_can_render(app_client):
@pytest.mark.asyncio
-async def test_prepare_jinja2_environment(app_client):
+async def test_hook_prepare_jinja2_environment(app_client):
template = app_client.ds.jinja_env.from_string(
"Hello there, {{ a|format_numeric }}", {"a": 3412341}
)
@@ -500,7 +500,7 @@ async def test_prepare_jinja2_environment(app_client):
assert "Hello there, 3,412,341" == rendered
-def test_publish_subcommand():
+def test_hook_publish_subcommand():
# This is hard to test properly, because publish subcommand plugins
# cannot be loaded using the --plugins-dir mechanism - they need
# to be installed using "pip install". So I'm cheating and taking
@@ -509,7 +509,7 @@ def test_publish_subcommand():
assert ["cloudrun", "heroku"] == cli.publish.list_commands({})
-def test_register_facet_classes(app_client):
+def test_hook_register_facet_classes(app_client):
response = app_client.get(
"/fixtures/compound_three_primary_keys.json?_dummy_facet=1"
)
@@ -549,7 +549,7 @@ def test_register_facet_classes(app_client):
] == response.json["suggested_facets"]
-def test_actor_from_request(app_client):
+def test_hook_actor_from_request(app_client):
app_client.get("/")
# Should have no actor
assert None == app_client.ds._last_request.scope["actor"]
@@ -558,7 +558,7 @@ def test_actor_from_request(app_client):
assert {"id": "bot"} == app_client.ds._last_request.scope["actor"]
-def test_actor_from_request_async(app_client):
+def test_hook_actor_from_request_async(app_client):
app_client.get("/")
# Should have no actor
assert None == app_client.ds._last_request.scope["actor"]
@@ -583,7 +583,7 @@ def test_existing_scope_actor_respected(app_client):
("no_match", None),
],
)
-async def test_permission_allowed(app_client, action, expected):
+async def test_hook_permission_allowed(app_client, action, expected):
actual = await app_client.ds.permission_allowed(
{"id": "actor"}, action, default=None
)
@@ -605,20 +605,20 @@ def test_actor_json(app_client):
("/not-async/", "This was not async"),
],
)
-def test_register_routes(app_client, path, body):
+def test_hook_register_routes(app_client, path, body):
response = app_client.get(path)
assert 200 == response.status
assert body == response.text
-def test_register_routes_post(app_client):
+def test_hook_register_routes_post(app_client):
response = app_client.post("/post/", {"this is": "post data"}, csrftoken_from=True)
assert 200 == response.status
assert "csrftoken" in response.json
assert "post data" == response.json["this is"]
-def test_register_routes_csrftoken(restore_working_directory, tmpdir_factory):
+def test_hook_register_routes_csrftoken(restore_working_directory, tmpdir_factory):
templates = tmpdir_factory.mktemp("templates")
(templates / "csrftoken_form.html").write_text(
"CSRFTOKEN: {{ csrftoken() }}", "utf-8"
@@ -629,13 +629,13 @@ def test_register_routes_csrftoken(restore_working_directory, tmpdir_factory):
assert "CSRFTOKEN: {}".format(expected_token) == response.text
-def test_register_routes_asgi(app_client):
+def test_hook_register_routes_asgi(app_client):
response = app_client.get("/three/")
assert {"hello": "world"} == response.json
assert "1" == response.headers["x-three"]
-def test_register_routes_add_message(app_client):
+def test_hook_register_routes_add_message(app_client):
response = app_client.get("/add-message/")
assert 200 == response.status
assert "Added message" == response.text
@@ -643,7 +643,7 @@ def test_register_routes_add_message(app_client):
assert [["Hello from messages", 1]] == decoded
-def test_register_routes_render_message(restore_working_directory, tmpdir_factory):
+def test_hook_register_routes_render_message(restore_working_directory, tmpdir_factory):
templates = tmpdir_factory.mktemp("templates")
(templates / "render_message.html").write_text('{% extends "base.html" %}', "utf-8")
with make_app_client(template_dir=templates) as client:
@@ -654,13 +654,13 @@ def test_register_routes_render_message(restore_working_directory, tmpdir_factor
@pytest.mark.asyncio
-async def test_startup(app_client):
+async def test_hook_startup(app_client):
await app_client.ds.invoke_startup()
assert app_client.ds._startup_hook_fired
assert 2 == app_client.ds._startup_hook_calculation
-def test_canned_queries(app_client):
+def test_hook_canned_queries(app_client):
queries = app_client.get("/fixtures.json").json["queries"]
queries_by_name = {q["name"]: q for q in queries}
assert {
@@ -675,23 +675,23 @@ def test_canned_queries(app_client):
} == queries_by_name["from_hook"]
-def test_canned_queries_non_async(app_client):
+def test_hook_canned_queries_non_async(app_client):
response = app_client.get("/fixtures/from_hook.json?_shape=array")
assert [{"1": 1, "actor_id": "null"}] == response.json
-def test_canned_queries_async(app_client):
+def test_hook_canned_queries_async(app_client):
response = app_client.get("/fixtures/from_async_hook.json?_shape=array")
assert [{"2": 2}] == response.json
-def test_canned_queries_actor(app_client):
+def test_hook_canned_queries_actor(app_client):
assert [{"1": 1, "actor_id": "bot"}] == app_client.get(
"/fixtures/from_hook.json?_bot=1&_shape=array"
).json
-def test_register_magic_parameters(restore_working_directory):
+def test_hook_register_magic_parameters(restore_working_directory):
with make_app_client(
extra_databases={"data.db": "create table logs (line text)"},
metadata={
@@ -719,7 +719,7 @@ def test_register_magic_parameters(restore_working_directory):
assert 4 == new_uuid.count("-")
-def test_forbidden(restore_working_directory):
+def test_hook_forbidden(restore_working_directory):
with make_app_client(
extra_databases={"data2.db": "create table logs (line text)"},
metadata={"allow": {}},
From 3a4c8ed36aa97211e46849d32a09f2f386f342dd Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 16 Aug 2020 11:09:53 -0700
Subject: [PATCH 0263/1871] Added columns argument to various extra_ plugin
hooks, closes #938
---
datasette/app.py | 5 +-
datasette/hookspecs.py | 12 +-
docs/plugin_hooks.rst | 254 +++++++++++++++++--------------------
tests/plugins/my_plugin.py | 13 +-
tests/test_plugins.py | 25 +++-
5 files changed, 159 insertions(+), 150 deletions(-)
diff --git a/datasette/app.py b/datasette/app.py
index 180ba246..2185a3ab 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -713,6 +713,7 @@ class Datasette:
template=template.name,
database=context.get("database"),
table=context.get("table"),
+ columns=context.get("columns"),
view_name=view_name,
request=request,
datasette=self,
@@ -729,6 +730,7 @@ class Datasette:
template=template.name,
database=context.get("database"),
table=context.get("table"),
+ columns=context.get("columns"),
view_name=view_name,
request=request,
datasette=self,
@@ -779,9 +781,10 @@ class Datasette:
template=template.name,
database=context.get("database"),
table=context.get("table"),
- datasette=self,
+ columns=context.get("columns"),
view_name=view_name,
request=request,
+ datasette=self,
):
if callable(hook):
hook = hook()
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 0e9c20cf..f7e90e4e 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -26,22 +26,26 @@ def prepare_jinja2_environment(env):
@hookspec
-def extra_css_urls(template, database, table, view_name, request, datasette):
+def extra_css_urls(template, database, table, columns, view_name, request, datasette):
"Extra CSS URLs added by this plugin"
@hookspec
-def extra_js_urls(template, database, table, view_name, request, datasette):
+def extra_js_urls(template, database, table, columns, view_name, request, datasette):
"Extra JavaScript URLs added by this plugin"
@hookspec
-def extra_body_script(template, database, table, view_name, request, datasette):
+def extra_body_script(
+ template, database, table, columns, view_name, request, datasette
+):
"Extra JavaScript code to be included in ")
json_data = r.search(app_client.get(path).text).group(1)
actual_data = json.loads(json_data)
@@ -286,6 +308,7 @@ def test_hook_extra_template_vars(restore_working_directory):
assert {
"template": "show_json.html",
"scope_path": "/-/metadata",
+ "columns": None,
} == extra_template_vars
extra_template_vars_from_awaitable = json.loads(
Soup(response.body, "html.parser")
From 8e7e6458a6787a06a4488798bd643dd7728b8a5b Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 16 Aug 2020 11:24:39 -0700
Subject: [PATCH 0264/1871] Fix bug with ?_nl=on and binary data, closes #914
---
datasette/renderer.py | 2 +-
tests/fixtures.py | 3 ++-
tests/test_api.py | 31 ++++++++++++++++++++++++++++++-
tests/test_html.py | 9 +++++++--
4 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/datasette/renderer.py b/datasette/renderer.py
index 3f921fe7..27a5092f 100644
--- a/datasette/renderer.py
+++ b/datasette/renderer.py
@@ -84,7 +84,7 @@ def json_renderer(args, data, view_name):
# Handle _nl option for _shape=array
nl = args.get("_nl", "")
if nl and shape == "array":
- body = "\n".join(json.dumps(item) for item in data)
+ body = "\n".join(json.dumps(item, cls=CustomJSONEncoder) for item in data)
content_type = "text/plain"
else:
body = json.dumps(data, cls=CustomJSONEncoder)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 139eff83..5bd063d9 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -663,7 +663,8 @@ CREATE VIEW searchable_view_configured_by_metadata AS
)
)
TABLE_PARAMETERIZED_SQL = [
- ("insert into binary_data (data) values (?);", [b"this is binary data"])
+ ("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]),
+ ("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]),
]
EXTRA_DATABASE_SQL = """
diff --git a/tests/test_api.py b/tests/test_api.py
index 1f93c1a7..22fa87d4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -105,7 +105,7 @@ def test_database_page(app_client):
"name": "binary_data",
"columns": ["data"],
"primary_keys": [],
- "count": 1,
+ "count": 2,
"hidden": False,
"fts_table": None,
"foreign_keys": {"incoming": [], "outgoing": []},
@@ -1793,3 +1793,32 @@ def test_null_foreign_keys_are_not_expanded(app_client):
def test_inspect_file_used_for_count(app_client_immutable_and_inspect_file):
response = app_client_immutable_and_inspect_file.get("/fixtures/sortable.json")
assert response.json["filtered_table_rows_count"] == 100
+
+
+@pytest.mark.parametrize(
+ "path,expected_json,expected_text",
+ [
+ (
+ "/fixtures/binary_data.json?_shape=array",
+ [
+ {"rowid": 1, "data": {"$base64": True, "encoded": "FRwCx60F/g=="}},
+ {"rowid": 2, "data": {"$base64": True, "encoded": "FRwDx60F/g=="}},
+ ],
+ None,
+ ),
+ (
+ "/fixtures/binary_data.json?_shape=array&_nl=on",
+ None,
+ (
+ '{"rowid": 1, "data": {"$base64": true, "encoded": "FRwCx60F/g=="}}\n'
+ '{"rowid": 2, "data": {"$base64": true, "encoded": "FRwDx60F/g=="}}'
+ ),
+ ),
+ ],
+)
+def test_binary_data_in_json(app_client, path, expected_json, expected_text):
+ response = app_client.get(path)
+ if expected_json:
+ assert response.json == expected_json
+ else:
+ assert response.text == expected_text
diff --git a/tests/test_html.py b/tests/test_html.py
index 89aa4d06..1a12b3ce 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -1134,8 +1134,13 @@ def test_binary_data_display(app_client):
[
'1 ',
'1 ',
- '<Binary\xa0data:\xa019\xa0bytes> ',
- ]
+ '<Binary\xa0data:\xa07\xa0bytes> ',
+ ],
+ [
+ '2 ',
+ '2 ',
+ '<Binary\xa0data:\xa07\xa0bytes> ',
+ ],
]
assert expected_tds == [
[str(td) for td in tr.select("td")] for tr in table.select("tbody tr")
From 52eabb019d4051084b21524bd0fd9c2731126985 Mon Sep 17 00:00:00 2001
From: Simon Willison
Date: Sun, 16 Aug 2020 11:56:31 -0700
Subject: [PATCH 0265/1871] Release 0.48
Refs #939, #938, #935, #914
---
README.md | 1 +
docs/changelog.rst | 12 ++++++++++++
docs/internals.rst | 2 ++
3 files changed, 15 insertions(+)
diff --git a/README.md b/README.md
index 9b49cc14..ee3246a5 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Datasette is aimed at data journalists, museum curators, archivists, local gover
## News
+ * 16th August 2020: [Datasette 0.48](https://docs.datasette.io/en/stable/changelog.html#v0-48) - Documentation now lives at [docs.datasette.io](https://docs.datasette.io/), improvements to the `extra_template_vars`, `extra_css_urls`, `extra_js_urls` and `extra_body_script` plugin hooks.
* 11th August 2020: [Datasette 0.47](https://docs.datasette.io/en/stable/changelog.html#v0-47) - Datasette can now be installed using Homebrew! `brew install simonw/datasette/datasette`. Also new: `datasette install name-of-plugin` and `datasette uninstall name-of-plugin` commands, and `datasette --get '/-/versions.json'` to output the result of Datasette HTTP calls on the command-line.
* 9th August 2020: [Datasette 0.46](https://docs.datasette.io/en/stable/changelog.html#v0-46) - security fix relating to CSRF protection for writable canned queries, a new logo, new debugging tools, improved file downloads and more.
* 6th August 2020: [GraphQL in Datasette with the new datasette-graphql plugin](https://simonwillison.net/2020/Aug/7/datasette-graphql/)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index bf53b6f3..d18dae80 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,18 @@
Changelog
=========
+.. _v0_48:
+
+0.48 (2020-08-16)
+-----------------
+
+- Datasette documentation now lives at `docs.datasette.io `__.
+- ``db.is_mutable`` property is now documented and tested, see :ref:`internals_database_introspection`.
+- The ``extra_template_vars``, ``extra_css_urls``, ``extra_js_urls`` and ``extra_body_script`` plugin hooks now all accept the same arguments. See :ref:`plugin_hook_extra_template_vars` for details. (`#939 `__)
+- Those hooks now accept a new ``columns`` argument detailing the table columns that will be rendered on that page. (`#938