Add actor= parameter to datasette.client methods (#2688)

`datasette.client.get(path, actor={"id": "root"}` now makes the internal request with that actor as `request.actor` - same for the other HTTP verb methods on `datasette.client`.

Upgraded relevant tests to use the new `actor=` mechanism.
This commit is contained in:
Simon Willison 2026-04-14 18:31:57 -07:00 committed by GitHub
commit 9c164572d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 149 additions and 53 deletions

View file

@ -2654,9 +2654,21 @@ class DatasetteClient:
path = f"http://localhost{path}"
return path
def _apply_actor(self, kwargs):
"""If ``actor=`` was supplied, convert it into a signed ds_actor cookie."""
actor = kwargs.pop("actor", None)
if actor is None:
return
cookies = dict(kwargs.get("cookies") or {})
if "ds_actor" in cookies:
raise TypeError("Cannot pass both actor= and a ds_actor cookie")
cookies["ds_actor"] = self.actor_cookie(actor)
kwargs["cookies"] = cookies
async def _request(self, method, path, skip_permission_checks=False, **kwargs):
from datasette.permissions import SkipPermissions
self._apply_actor(kwargs)
with _DatasetteClientContext():
if skip_permission_checks:
with SkipPermissions():
@ -2722,6 +2734,7 @@ class DatasetteClient:
from datasette.permissions import SkipPermissions
avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None)
self._apply_actor(kwargs)
with _DatasetteClientContext():
if skip_permission_checks:
with SkipPermissions():

View file

@ -1312,6 +1312,28 @@ These methods can be used with :ref:`internals_datasette_urls` - for example:
For documentation on available ``**kwargs`` options and the shape of the HTTPX Response object refer to the `HTTPX Async documentation <https://www.python-httpx.org/async/>`__.
.. _internals_datasette_client_actor:
Authenticating as an actor
~~~~~~~~~~~~~~~~~~~~~~~~~~
All ``datasette.client`` methods accept an optional ``actor=`` parameter. When set to a dictionary describing an actor, the request is made with a signed ``ds_actor`` cookie identifying that actor — as if the request had been made by a user who is signed in as that actor.
This is a convenient shorthand equivalent to signing the cookie manually using ``datasette.client.actor_cookie()``.
Example usage:
.. code-block:: python
response = await datasette.client.get(
"/-/actor.json", actor={"id": "root"}
)
assert response.json() == {"actor": {"id": "root"}}
This parameter works with all HTTP methods (``get``, ``post``, ``put``, ``patch``, ``delete``, ``options``, ``head``) and the generic ``request`` method.
Passing both ``actor=`` and a ``ds_actor`` cookie via ``cookies=`` raises a ``TypeError``. Other unrelated cookies can be combined with ``actor=``.
Bypassing permission checks
~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -553,8 +553,7 @@ async def test_actions_json(ds_client):
original_root_enabled = ds_client.ds.root_enabled
try:
ds_client.ds.root_enabled = True
cookies = {"ds_actor": ds_client.actor_cookie({"id": "root"})}
response = await ds_client.get("/-/actions.json", cookies=cookies)
response = await ds_client.get("/-/actions.json", actor={"id": "root"})
data = response.json()
finally:
ds_client.ds.root_enabled = original_root_enabled

View file

@ -933,9 +933,7 @@ async def test_set_column_type_ui_data_includes_applicable_types(
await ds_ct_editor_permission.invoke_startup()
response = await ds_ct_editor_permission.client.get(
"/data/posts",
cookies={
"ds_actor": ds_ct_editor_permission.client.actor_cookie({"id": "editor"})
},
actor={"id": "editor"},
)
assert response.status_code == 200
data = _window_data_from_html(response.text, "_setColumnTypeData")

View file

@ -1003,10 +1003,10 @@ async def test_navigation_menu_links(
# Enable root user if testing with root actor
if actor_id == "root":
ds_client.ds.root_enabled = True
cookies = {}
kwargs = {}
if actor_id:
cookies = {"ds_actor": ds_client.actor_cookie({"id": actor_id})}
html = (await ds_client.get("/", cookies=cookies)).text
kwargs["actor"] = {"id": actor_id}
html = (await ds_client.get("/", **kwargs)).text
soup = Soup(html, "html.parser")
details = soup.find("nav").find("details")
if not actor_id:
@ -1215,8 +1215,7 @@ async def test_actions_page(ds_client):
original_root_enabled = ds_client.ds.root_enabled
try:
ds_client.ds.root_enabled = True
cookies = {"ds_actor": ds_client.actor_cookie({"id": "root"})}
response = await ds_client.get("/-/actions", cookies=cookies)
response = await ds_client.get("/-/actions", actor={"id": "root"})
assert response.status_code == 200
assert "Registered actions" in response.text
assert "<th>Name</th>" in response.text
@ -1233,8 +1232,7 @@ async def test_actions_page_does_not_display_none_string(ds_client):
original_root_enabled = ds_client.ds.root_enabled
try:
ds_client.ds.root_enabled = True
cookies = {"ds_actor": ds_client.actor_cookie({"id": "root"})}
response = await ds_client.get("/-/actions", cookies=cookies)
response = await ds_client.get("/-/actions", actor={"id": "root"})
assert response.status_code == 200
assert "<code>None</code>" not in response.text
finally:
@ -1247,11 +1245,11 @@ async def test_permission_debug_tabs_with_query_string(ds_client):
original_root_enabled = ds_client.ds.root_enabled
try:
ds_client.ds.root_enabled = True
cookies = {"ds_actor": ds_client.actor_cookie({"id": "root"})}
actor = {"id": "root"}
# Test /-/allowed with query string
response = await ds_client.get(
"/-/allowed?action=view-table&page_size=50", cookies=cookies
"/-/allowed?action=view-table&page_size=50", actor=actor
)
assert response.status_code == 200
# Check that Rules and Check tabs have the query string
@ -1263,7 +1261,7 @@ async def test_permission_debug_tabs_with_query_string(ds_client):
# Test /-/rules with query string
response = await ds_client.get(
"/-/rules?action=view-database&parent=test", cookies=cookies
"/-/rules?action=view-database&parent=test", actor=actor
)
assert response.status_code == 200
# Check that Allowed and Check tabs have the query string
@ -1271,7 +1269,7 @@ async def test_permission_debug_tabs_with_query_string(ds_client):
assert 'href="/-/check?action=view-database&amp;parent=test"' in response.text
# Test /-/check with query string
response = await ds_client.get("/-/check?action=execute-sql", cookies=cookies)
response = await ds_client.get("/-/check?action=execute-sql", actor=actor)
assert response.status_code == 200
# Check that Allowed and Rules tabs have the query string
assert 'href="/-/allowed?action=execute-sql"' in response.text

View file

@ -311,3 +311,76 @@ async def test_in_client_with_skip_permission_checks():
assert all(in_client_values), f"Expected all True, got {in_client_values}"
finally:
ds.pm.unregister(name="test_in_client_skip_plugin")
@pytest.mark.asyncio
async def test_actor_parameter_sets_cookie(datasette):
"""Passing actor= should sign a ds_actor cookie and authenticate the request."""
response = await datasette.client.get("/-/actor.json", actor={"id": "root"})
assert response.status_code == 200
assert response.json() == {"actor": {"id": "root"}}
@pytest.mark.asyncio
async def test_actor_parameter_works_with_request_method(datasette):
response = await datasette.client.request(
"GET", "/-/actor.json", actor={"id": "root"}
)
assert response.status_code == 200
assert response.json() == {"actor": {"id": "root"}}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method", ["get", "post", "options", "head", "put", "patch", "delete"]
)
async def test_actor_parameter_all_http_methods(datasette, method):
"""actor= should not cause errors on any HTTP verb wrapper."""
client_method = getattr(datasette.client, method)
# Just verify no TypeError about unexpected 'actor' kwarg
response = await client_method("/", actor={"id": "root"})
assert isinstance(response, httpx.Response)
@pytest.mark.asyncio
async def test_actor_parameter_conflicts_with_ds_actor_cookie(datasette):
"""Passing both actor= and a ds_actor cookie should raise TypeError."""
with pytest.raises(TypeError, match="actor"):
await datasette.client.get(
"/-/actor.json",
actor={"id": "root"},
cookies={"ds_actor": datasette.client.actor_cookie({"id": "other"})},
)
@pytest.mark.asyncio
async def test_actor_parameter_merges_with_other_cookies(datasette):
"""actor= should coexist with unrelated cookies."""
response = await datasette.client.get(
"/-/actor.json",
actor={"id": "root"},
cookies={"unrelated": "value"},
)
assert response.status_code == 200
assert response.json() == {"actor": {"id": "root"}}
@pytest.mark.asyncio
async def test_actor_parameter_with_skip_permission_checks(
datasette_with_permissions,
):
"""actor= should be compatible with skip_permission_checks."""
ds = datasette_with_permissions
# Non-admin actor with skip_permission_checks=True should get 200
response = await ds.client.get(
"/test_db.json",
actor={"id": "user"},
skip_permission_checks=True,
)
assert response.status_code == 200
# Admin actor on its own should also get 200
response = await ds.client.get("/test_db.json", actor={"id": "admin"})
assert response.status_code == 200
# Non-admin actor should get 403
response = await ds.client.get("/test_db.json", actor={"id": "user"})
assert response.status_code == 403

View file

@ -117,9 +117,7 @@ async def test_allowed_json_with_actor(ds_with_permissions):
"""Test /-/allowed.json includes actor information."""
response = await ds_with_permissions.client.get(
"/-/allowed.json?action=view-table",
cookies={
"ds_actor": ds_with_permissions.client.actor_cookie({"id": "test_user"})
},
actor={"id": "test_user"},
)
assert response.status_code == 200
data = response.json()
@ -252,7 +250,7 @@ async def test_rules_json_basic(
# Use root actor for rules endpoint (requires permissions-debug)
response = await ds_with_permissions.client.get(
path,
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == expected_status
data = response.json()
@ -264,7 +262,7 @@ async def test_rules_json_response_structure(ds_with_permissions):
"""Test that /-/rules.json returns the expected structure."""
response = await ds_with_permissions.client.get(
"/-/rules.json?action=view-instance",
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
@ -294,7 +292,7 @@ async def test_rules_json_includes_all_rules(ds_with_permissions):
# Root user should see rules for everything
response = await ds_with_permissions.client.get(
"/-/rules.json?action=view-table",
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
@ -326,7 +324,7 @@ async def test_rules_json_pagination():
# Test basic pagination structure - just verify it returns paginated results
response = await ds.client.get(
"/-/rules.json?action=view-table&page_size=2&page=1",
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
@ -343,7 +341,7 @@ async def test_rules_json_with_actor(ds_with_permissions):
# Use root actor (rules endpoint requires permissions-debug)
response = await ds_with_permissions.client.get(
"/-/rules.json?action=view-table",
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
@ -374,7 +372,7 @@ async def test_root_user_respects_settings_deny():
# Root user should NOT see the denied database
response = await ds.client.get(
"/-/allowed.json?action=view-database",
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
@ -415,7 +413,7 @@ async def test_root_user_respects_settings_deny_tables():
# Root user should NOT see tables from the content database
response = await ds.client.get(
"/-/allowed.json?action=view-table",
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
@ -475,7 +473,7 @@ async def test_execute_sql_requires_view_database():
# User should NOT have execute-sql permission because view-database is denied
response = await ds.client.get(
"/-/allowed.json?action=execute-sql",
cookies={"ds_actor": ds.client.actor_cookie({"id": "test_user"})},
actor={"id": "test_user"},
)
assert response.status_code == 200
data = response.json()
@ -491,7 +489,7 @@ async def test_execute_sql_requires_view_database():
# (may be 403 or 302 redirect to login/error page depending on middleware)
response = await ds.client.get(
"/secret?sql=SELECT+1",
cookies={"ds_actor": ds.client.actor_cookie({"id": "test_user"})},
actor={"id": "test_user"},
)
assert response.status_code in (302, 403), (
f"Expected 302 or 403 when trying to execute SQL without view-database permission, "

View file

@ -1171,10 +1171,10 @@ async def test_api_explorer_visibility(
try:
prev_config = perms_ds.config
perms_ds.config = config or {}
cookies = {}
kwargs = {}
if is_logged_in:
cookies = {"ds_actor": perms_ds.client.actor_cookie({"id": "user"})}
response = await perms_ds.client.get("/-/api", cookies=cookies)
kwargs["actor"] = {"id": "user"}
response = await perms_ds.client.get("/-/api", **kwargs)
if expected_visible_tables:
assert response.status_code == 200
# Search HTML for stuff matching:
@ -1208,8 +1208,7 @@ async def test_view_table_token_cannot_gain_access_without_base_permission(perms
# Restricted token claims access to perms_ds_two/t1 only
"_r": {"r": {"perms_ds_two": {"t1": ["vt"]}}},
}
cookies = {"ds_actor": perms_ds.client.actor_cookie(actor)}
response = await perms_ds.client.get("/perms_ds_two/t1.json", cookies=cookies)
response = await perms_ds.client.get("/perms_ds_two/t1.json", actor=actor)
assert response.status_code == 403
finally:
perms_ds.config = previous_config
@ -1328,7 +1327,7 @@ async def test_actor_restrictions(
if restrictions:
actor["_r"] = restrictions
method = getattr(perms_ds.client, verb)
kwargs = {"cookies": {"ds_actor": perms_ds.client.actor_cookie(actor)}}
kwargs = {"actor": actor}
if body:
kwargs["json"] = body
perms_ds._permission_checks.clear()
@ -1459,7 +1458,7 @@ async def test_actor_restrictions_do_not_expand_allowed_resources(perms_ds):
# And explicit permission checks should still deny
response = await perms_ds.client.get(
"/perms_ds_one/t1.json",
cookies={"ds_actor": perms_ds.client.actor_cookie(actor)},
actor=actor,
)
assert response.status_code == 403
finally:
@ -1527,18 +1526,17 @@ async def test_actor_restrictions_json_endpoints_show_filtered_listings(perms_ds
"""Test that /.json and /db.json show correct filtered listings - issue #2534"""
actor = {"id": "user", "_r": {"r": {"perms_ds_one": {"t1": ["vt"]}}}}
cookies = {"ds_actor": perms_ds.client.actor_cookie(actor)}
# /.json should be 403 (no view-instance permission)
response = await perms_ds.client.get("/.json", cookies=cookies)
response = await perms_ds.client.get("/.json", actor=actor)
assert response.status_code == 403
# /perms_ds_one.json should be 403 (no view-database permission)
response = await perms_ds.client.get("/perms_ds_one.json", cookies=cookies)
response = await perms_ds.client.get("/perms_ds_one.json", actor=actor)
assert response.status_code == 403
# /perms_ds_one/t1.json should be 200
response = await perms_ds.client.get("/perms_ds_one/t1.json", cookies=cookies)
response = await perms_ds.client.get("/perms_ds_one/t1.json", actor=actor)
assert response.status_code == 200
@ -1547,10 +1545,9 @@ async def test_actor_restrictions_view_instance_only(perms_ds):
"""Test actor restricted to view-instance only - issue #2534"""
actor = {"id": "user", "_r": {"a": ["vi"]}}
cookies = {"ds_actor": perms_ds.client.actor_cookie(actor)}
# /.json should be 200 (has view-instance permission)
response = await perms_ds.client.get("/.json", cookies=cookies)
response = await perms_ds.client.get("/.json", actor=actor)
assert response.status_code == 200
# But no databases should be visible (no view-database permission)

View file

@ -1024,7 +1024,7 @@ async def test_hook_view_actions(ds_client):
assert get_actions_links(response.text) == []
response_2 = await ds_client.get(
"/fixtures/simple_view",
cookies={"ds_actor": ds_client.actor_cookie({"id": "bob"})},
actor={"id": "bob"},
)
assert ">View actions<" in response_2.text
assert sorted(
@ -1088,7 +1088,7 @@ async def test_hook_row_actions(ds_client):
response_2 = await ds_client.get(
"/fixtures/facet_cities/1",
cookies={"ds_actor": ds_client.actor_cookie({"id": "sam"})},
actor={"id": "sam"},
)
assert get_actions_links(response_2.text) == [
{
@ -1116,9 +1116,7 @@ async def test_hook_homepage_actions(ds_client):
# No button for anonymous users
assert "<span>Homepage actions</span>" not in response.text
# Signed in user gets an action
response2 = await ds_client.get(
"/", cookies={"ds_actor": ds_client.actor_cookie({"id": "troy"})}
)
response2 = await ds_client.get("/", actor={"id": "troy"})
assert "<span>Homepage actions</span>" in response2.text
assert get_actions_links(response2.text) == [
{

View file

@ -151,7 +151,7 @@ async def test_schema_permission_enforcement(schema_ds, url):
# Authenticated user with permission should succeed
response = await schema_ds.client.get(
url,
cookies={"ds_actor": schema_ds.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
@ -171,7 +171,7 @@ async def test_instance_schema_respects_database_permissions(schema_ds):
# Authenticated user should see all databases
response = await schema_ds.client.get(
"/-/schema.json",
cookies={"ds_actor": schema_ds.client.actor_cookie({"id": "root"})},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()

View file

@ -86,7 +86,7 @@ async def test_tables_search_with_auth(ds_with_tables):
# Editor user should see content.articles
response = await ds_with_tables.client.get(
"/-/tables.json?q=articles",
cookies={"ds_actor": ds_with_tables.client.actor_cookie({"id": "editor"})},
actor={"id": "editor"},
)
assert response.status_code == 200
data = response.json()
@ -104,7 +104,7 @@ async def test_tables_search_partial_match(ds_with_tables):
# Search for "com" should match "comments"
response = await ds_with_tables.client.get(
"/-/tables.json?q=com",
cookies={"ds_actor": ds_with_tables.client.actor_cookie({"id": "user"})},
actor={"id": "user"},
)
assert response.status_code == 200
data = response.json()
@ -120,7 +120,7 @@ async def test_tables_search_respects_database_permissions(ds_with_tables):
# Even authenticated users shouldn't see it because database is denied
response = await ds_with_tables.client.get(
"/-/tables.json?q=secrets",
cookies={"ds_actor": ds_with_tables.client.actor_cookie({"id": "user"})},
actor={"id": "user"},
)
assert response.status_code == 200
data = response.json()
@ -135,7 +135,7 @@ async def test_tables_search_respects_table_permissions(ds_with_tables):
# Regular authenticated user searching for "users"
response = await ds_with_tables.client.get(
"/-/tables.json?q=users",
cookies={"ds_actor": ds_with_tables.client.actor_cookie({"id": "regular"})},
actor={"id": "regular"},
)
assert response.status_code == 200
data = response.json()
@ -150,7 +150,7 @@ async def test_tables_search_response_structure(ds_with_tables):
"""Test that response has correct structure."""
response = await ds_with_tables.client.get(
"/-/tables.json?q=users",
cookies={"ds_actor": ds_with_tables.client.actor_cookie({"id": "user"})},
actor={"id": "user"},
)
assert response.status_code == 200
data = response.json()