diff --git a/datasette/app.py b/datasette/app.py
index 6ab32f9e2e..16545cff98 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -2654,9 +2654,21 @@ def _fix(self, path, avoid_path_rewrites=False):
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 @@ async def request(self, method, path, skip_permission_checks=False, **kwargs):
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():
diff --git a/docs/internals.rst b/docs/internals.rst
index 1693a241a8..ba9d313163 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -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
None" 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&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
diff --git a/tests/test_internals_datasette_client.py b/tests/test_internals_datasette_client.py
index 326fcdc0e1..ccac280b93 100644
--- a/tests/test_internals_datasette_client.py
+++ b/tests/test_internals_datasette_client.py
@@ -311,3 +311,76 @@ async def test_view(datasette):
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
diff --git a/tests/test_permission_endpoints.py b/tests/test_permission_endpoints.py
index 84f3370fb6..e25be23ec3 100644
--- a/tests/test_permission_endpoints.py
+++ b/tests/test_permission_endpoints.py
@@ -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 @@ def permission_resources_sql(self, datasette, actor, action):
# 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 @@ def permission_resources_sql(self, datasette, actor, action):
# (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, "
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 04195f75f4..0c09e77349 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -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)
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index c9de1c5728..083e23a01f 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -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 "Homepage actions" 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 "Homepage actions" in response2.text
assert get_actions_links(response2.text) == [
{
diff --git a/tests/test_schema_endpoints.py b/tests/test_schema_endpoints.py
index 50742df2cd..c95d8614f6 100644
--- a/tests/test_schema_endpoints.py
+++ b/tests/test_schema_endpoints.py
@@ -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()
diff --git a/tests/test_search_tables.py b/tests/test_search_tables.py
index 34b377067d..b901c0b370 100644
--- a/tests/test_search_tables.py
+++ b/tests/test_search_tables.py
@@ -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()