Skip to content

Commit

Permalink
/-/config page, closes #2254
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Feb 6, 2024
1 parent 85a1dfe commit 1e901aa
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 23 deletions.
14 changes: 8 additions & 6 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
tilde_decode,
to_css_class,
urlsafe_components,
redact_keys,
row_sql_params_pks,
)
from .utils.asgi import (
Expand Down Expand Up @@ -1374,6 +1375,11 @@ async def _asset_urls(self, key, template, context, request, view_name):
output.append(script)
return output

def _config(self):
return redact_keys(
self.config, ("secret", "key", "password", "token", "hash", "dsn")
)

def _routes(self):
routes = []

Expand Down Expand Up @@ -1433,12 +1439,8 @@ def add_route(view, regex):
r"/-/settings(\.(?P<format>json))?$",
)
add_route(
permanent_redirect("/-/settings.json"),
r"/-/config.json",
)
add_route(
permanent_redirect("/-/settings"),
r"/-/config",
JsonDataView.as_view(self, "config.json", lambda: self._config()),
r"/-/config(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(self, "threads.json", self._threads),
Expand Down
28 changes: 28 additions & 0 deletions datasette/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import types
import secrets
import shutil
from typing import Iterable
import urllib
import yaml
from .shutil_backport import copytree
Expand Down Expand Up @@ -1327,3 +1328,30 @@ def prune_empty_dicts(d):

recursive_move(source, destination)
prune_empty_dicts(source)


def redact_keys(original: dict, key_patterns: Iterable) -> dict:
"""
Recursively redact sensitive keys in a dictionary based on given patterns
:param original: The original dictionary
:param key_patterns: A list of substring patterns to redact
:return: A copy of the original dictionary with sensitive values redacted
"""

def redact(data):
if isinstance(data, dict):
return {
k: (
redact(v)
if not any(pattern in k for pattern in key_patterns)
else "***"
)
for k, v in data.items()
}
elif isinstance(data, list):
return [redact(item) for item in data]
else:
return data

return redact(original)
4 changes: 2 additions & 2 deletions datasette/views/special.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async def get(self, request):
if self.ds.cors:
add_cors_headers(headers)
return Response(
json.dumps(data),
json.dumps(data, default=repr),
content_type="application/json; charset=utf-8",
headers=headers,
)
Expand All @@ -53,7 +53,7 @@ async def get(self, request):
request=request,
context={
"filename": self.filename,
"data_json": json.dumps(data, indent=4),
"data_json": json.dumps(data, indent=4, default=repr),
},
)

Expand Down
11 changes: 10 additions & 1 deletion docs/introspection.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Shows a list of currently installed plugins and their versions. `Plugins example
Add ``?all=1`` to include details of the default plugins baked into Datasette.

.. _JsonDataView_config:
.. _JsonDataView_settings:

/-/settings
-----------
Expand All @@ -105,6 +105,15 @@ Shows the :ref:`settings` for this instance of Datasette. `Settings example <htt
"sql_time_limit_ms": 1000
}
.. _JsonDataView_config:

/-/config
---------

Shows the :ref:`configuration <configuration>` for this instance of Datasette. This is generally the contents of the :ref:`datasette.yaml or datasette.json <configuration_reference>` file, which can include plugin configuration as well.

Any keys that include the one of the following substrings in their names will be returned as redacted ``***`` output, to help avoid accidentally leaking private configuration information: ``secret``, ``key``, ``password``, ``token``, ``hash``, ``dsn``.

.. _JsonDataView_databases:

/-/databases
Expand Down
50 changes: 36 additions & 14 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,20 +846,6 @@ async def test_settings_json(ds_client):
}


@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_redirect",
(
("/-/config.json", "/-/settings.json"),
("/-/config", "/-/settings"),
),
)
async def test_config_redirects_to_settings(ds_client, path, expected_redirect):
response = await ds_client.get(path)
assert response.status_code == 301
assert response.headers["Location"] == expected_redirect


test_json_columns_default_expected = [
{"intval": 1, "strval": "s", "floatval": 0.5, "jsonval": '{"foo": "bar"}'}
]
Expand Down Expand Up @@ -1039,3 +1025,39 @@ async def test_tilde_encoded_database_names(db_name):
# And the JSON for that database
response2 = await ds.client.get(path + ".json")
assert response2.status_code == 200


@pytest.mark.asyncio
@pytest.mark.parametrize(
"config,expected",
(
({}, {}),
({"plugins": {"datasette-foo": "bar"}}, {"plugins": {"datasette-foo": "bar"}}),
# Test redaction
(
{
"plugins": {
"datasette-auth": {"secret_key": "key"},
"datasette-foo": "bar",
"datasette-auth2": {"password": "password"},
"datasette-sentry": {
"dsn": "sentry:///foo",
},
}
},
{
"plugins": {
"datasette-auth": {"secret_key": "***"},
"datasette-foo": "bar",
"datasette-auth2": {"password": "***"},
"datasette-sentry": {"dsn": "***"},
}
},
),
),
)
async def test_config_json(config, expected):
"/-/config.json should return redacted configuration"
ds = Datasette(config=config)
response = await ds.client.get("/-/config.json")
assert response.json() == expected

0 comments on commit 1e901aa

Please sign in to comment.