Filter /-/databases by view-database permission

/-/databases previously listed every attached database (including
filesystem paths and sizes) to any actor with view-instance, while the
homepage and every other endpoint filtered by view-database. The
endpoint now only lists databases the current actor is allowed to view.

JsonDataView data callbacks may now be async.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
This commit is contained in:
Claude 2026-07-04 14:00:30 +00:00
commit f091b6dab1
No known key found for this signature in database
6 changed files with 55 additions and 10 deletions

View file

@ -2168,6 +2168,18 @@ class Datasette:
for name, d in self.databases.items()
]
async def _connected_databases_for_actor(self, actor):
page = await self.allowed_resources("view-database", actor)
allowed_names = {resource.parent async for resource in page.all()}
return [
database
for database in self._connected_databases()
if database["name"] in allowed_names
]
async def _databases_data(self, request):
return {"databases": await self._connected_databases_for_actor(request.actor)}
def _versions(self):
conn = sqlite3.connect(":memory:")
self._prepare_connection(conn, "_memory")
@ -2574,7 +2586,8 @@ class Datasette:
JsonDataView.as_view(
self,
"databases.json",
lambda: {"databases": self._connected_databases()},
self._databases_data,
needs_request=True,
),
r"/-/databases(\.(?P<format>json))?$",
)

View file

@ -53,9 +53,9 @@ class JsonDataView(BaseView):
if self.permission:
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
if self.needs_request:
data = self.data_callback(request)
data = await await_me_maybe(self.data_callback(request))
else:
data = self.data_callback()
data = await await_me_maybe(self.data_callback())
# Return JSON or HTML depending on format parameter
as_format = request.url_vars.get("format")

View file

@ -137,7 +137,7 @@ Any keys that include the one of the following substrings in their names will be
/-/databases
------------
Shows currently attached databases. `Databases example <https://latest.datasette.io/-/databases>`_:
Shows currently attached databases that the current actor is allowed to view, based on the ``view-database`` permission. `Databases example <https://latest.datasette.io/-/databases>`_:
.. code-block:: json

View file

@ -262,8 +262,7 @@ Permission `view-instance`. No parameters.
Response: `{"ok": true, "databases": [...]}` — each database is
`{"name", "route", "path", "size", "is_mutable", "is_memory", "hash"}`.
**All attached databases are listed regardless of per-database
`view-database` permissions**.
Only databases the actor is allowed to `view-database` are listed.
### GET /-/actor(.json)

View file

@ -260,12 +260,13 @@ Concerns:
## 6. Permissions and security consistency (P1/P2)
- **(P1) `/-/databases.json` ignores per-database permissions** — it lists
- ~~**(P1) `/-/databases.json` ignores per-database permissions** — it lists
every attached database (name, path on disk, size) to any actor holding
`view-instance` (app.py:2157-2169), while the homepage and every other
endpoint filter by `view-database`. On a public instance with private
databases this leaks filesystem paths and database names. Filter it, or
gate it behind `permissions-debug`.
gate it behind `permissions-debug`.~~ ✅ **Done** — the endpoint now
filters through `allowed_resources("view-database", actor)`.
- **(P2) `/db/-/schema` checks existence before permission**
(views/special.py:1308-1317): an actor without `view-database` can
distinguish "database exists" (403) from "does not exist" (404).
@ -377,8 +378,8 @@ Two details make tiering urgent rather than optional:
SQL errors).
4. ~~Wrap `/-/plugins`, `/-/databases`, `/-/actions` top-level arrays in
objects (§2).~~ ✅ Done.
5. Filter `/-/databases.json` by `view-database` or gate it behind
`permissions-debug` (§6).
5. ~~Filter `/-/databases.json` by `view-database` or gate it behind
`permissions-debug` (§6).~~ ✅ Done.
6. 401 (not silent-anonymous) for invalid/expired bearer tokens (§1c).
7. Publish explicit stability tiers, including extras and pagination-token
opacity (§9).

View file

@ -1800,3 +1800,35 @@ async def test_root_allow_block_with_table_restricted_actor():
actor=admin_actor,
)
assert result is True
@pytest.mark.asyncio
async def test_databases_json_respects_view_database(tmp_path_factory):
# https://github.com/simonw/datasette - /-/databases should not list
# databases the actor is not allowed to view
db_directory = tmp_path_factory.mktemp("dbs")
from datasette.utils import sqlite3 as _sqlite3
paths = []
for name in ("public", "private"):
path = str(db_directory / "{}.db".format(name))
conn = _sqlite3.connect(path)
conn.execute("vacuum")
conn.close()
paths.append(path)
ds = Datasette(
paths,
config={"databases": {"private": {"allow": {"id": "root"}}}},
)
ds.root_enabled = True
await ds.invoke_startup()
try:
anon_response = await ds.client.get("/-/databases.json")
assert anon_response.status_code == 200
anon_names = {db["name"] for db in anon_response.json()["databases"]}
assert anon_names == {"public"}
root_response = await ds.client.get("/-/databases.json", actor={"id": "root"})
root_names = {db["name"] for db in root_response.json()["databases"]}
assert root_names == {"public", "private"}
finally:
ds.close()