Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
22 changes: 22 additions & 0 deletions docs/internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
3 changes: 1 addition & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions tests/test_column_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 9 additions & 11 deletions tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -1263,15 +1261,15 @@ 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
assert 'href="/-/allowed?action=view-database&amp;parent=test"' in response.text
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
Expand Down
73 changes: 73 additions & 0 deletions tests/test_internals_datasette_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 10 additions & 12 deletions tests/test_permission_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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, "
Expand Down
23 changes: 10 additions & 13 deletions tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand All @@ -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)
Expand Down
Loading
Loading