diff --git a/datasette/app.py b/datasette/app.py index 4b9807b0..3f2876ec 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -798,7 +798,18 @@ class DatasetteRouter(AsgiRouter): and scope.get("scheme") != "https" ): scope = dict(scope, scheme="https") - return await super().route_path(scope, receive, send, path) + # Handle authentication + actor = None + for actor in pm.hook.actor_from_request( + datasette=self.ds, request=Request(scope, receive) + ): + if callable(actor): + actor = actor() + if asyncio.iscoroutine(actor): + actor = await actor + if actor: + break + return await super().route_path(dict(scope, actor=actor), receive, send, path) async def handle_404(self, scope, receive, send, exception=None): # If URL has a trailing slash, redirect to URL without it diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 65c1c859..71d06661 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -66,5 +66,5 @@ def actor_from_request(datasette, request): @hookspec -def permission_allowed(actor, action, resource_type, resource_identifier): +def permission_allowed(datasette, actor, action, resource_type, resource_identifier): "Check if actor is allowed to perfom this action - return True, False or None" diff --git a/docs/plugins.rst b/docs/plugins.rst index 09e8f5e3..fb2843f4 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -957,6 +957,29 @@ This is part of Datasette's authentication and permissions system. The function If it cannot authenticate an actor, it should return ``None``. Otherwise it should return a dictionary representing that actor. +Instead of returning a dictionary, this function can return an awaitable function which itself returns either ``None`` or a dictionary. This is useful for authentication functions that need to make a database query - for example: + +.. code-block:: python + + from datasette import hookimpl + + @hookimpl + def actor_from_request(datasette, request): + async def inner(): + token = request.args.get("_token") + if not token: + return None + # Look up ?_token=xxx in sessions table + result = await datasette.get_database().execute( + "select count(*) from sessions where token = ?", [token] + ) + if result.first()[0]: + return {"token": token} + else: + return None + + return inner + .. _plugin_permission_allowed: permission_allowed(datasette, actor, action, resource_type, resource_identifier) diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 10d7e7e6..305cb3b7 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -126,3 +126,11 @@ class DummyFacet(Facet): facet_results = {} facets_timed_out = [] return facet_results, facets_timed_out + + +@hookimpl +def actor_from_request(datasette, request): + if request.args.get("_bot"): + return {"id": "bot"} + else: + return None diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index c9e7c78f..0a5cbba5 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -95,3 +95,15 @@ def asgi_wrapper(datasette): return add_x_databases_header return wrap_with_databases_header + + +@hookimpl +def actor_from_request(datasette, request): + async def inner(): + if request.args.get("_bot2"): + result = await datasette.get_database().execute("select 1 + 1") + return {"id": "bot2", "1+1": result.first()[0]} + else: + return None + + return inner diff --git a/tests/test_plugins.py b/tests/test_plugins.py index a34328a9..3ad26986 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -503,3 +503,27 @@ def test_register_facet_classes(app_client): "toggle_url": "http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_facet=pk3", }, ] == data["suggested_facets"] + + +def test_actor_from_request(app_client): + app_client.get("/") + # Should have no actor + assert None == app_client.ds._last_request.scope["actor"] + app_client.get("/?_bot=1") + # Should have bot actor + assert {"id": "bot"} == app_client.ds._last_request.scope["actor"] + + +def test_actor_from_request_async(app_client): + app_client.get("/") + # Should have no actor + assert None == app_client.ds._last_request.scope["actor"] + app_client.get("/?_bot2=1") + # Should have bot2 actor + assert {"id": "bot2", "1+1": 2} == app_client.ds._last_request.scope["actor"] + + +@pytest.mark.xfail +def test_permission_allowed(app_client): + # TODO + assert False