From 8871c20ea01411c7bb28aa1308cbe26af2a202bb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 30 May 2020 13:24:00 -0700 Subject: [PATCH 1/8] actor_from_request and permission_allowed hookspecs, refs #699 --- datasette/hookspecs.py | 10 ++++++++++ docs/plugins.rst | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index c2fc012696..65c1c85946 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -58,3 +58,13 @@ def register_output_renderer(datasette): @hookspec def register_facet_classes(): "Register Facet subclasses" + + +@hookspec +def actor_from_request(datasette, request): + "Return an actor dictionary based on the incoming request" + + +@hookspec +def permission_allowed(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 f08f121771..09e8f5e350 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -941,3 +941,40 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att return wrap_with_databases_header Examples: `datasette-auth-github `_, `datasette-search-all `_, `datasette-media `_ + +.. _plugin_actor_from_request: + +actor_from_request(datasette, request) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``request`` - object + The current HTTP :ref:`internals_request`. + +This is part of Datasette's authentication and permissions system. The function should attempt to authenticate an actor (either a user or an API actor of some sort) based on information in the request. + +If it cannot authenticate an actor, it should return ``None``. Otherwise it should return a dictionary representing that actor. + +.. _plugin_permission_allowed: + +permission_allowed(datasette, actor, action, resource_type, resource_identifier) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary + The current actor, as decided by :ref:`plugin_actor_from_request`. + +``action`` - string + The action to be performed, e.g. ``"edit-table"``. + +``resource_type`` - string + The type of resource being acted on, e.g. ``"table"``. + +``resource`` - string + An identifier for the individual resource, e.g. the name of the table. + +Called to check that an actor has permission to perform an action on a resource. Can return ``True`` if the action is allowed, ``False`` if the action is not allowed or ``None`` if the plugin does not have an opinion one way or the other. From 885efabe5e0c10aca9816b8c41f5d5267e2b893d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 30 May 2020 15:06:33 -0700 Subject: [PATCH 2/8] Implemented actor_from_request with tests, refs #699 Also added datasette argument to permission_allowed hook --- datasette/app.py | 13 ++++++++++++- datasette/hookspecs.py | 2 +- docs/plugins.rst | 23 +++++++++++++++++++++++ tests/plugins/my_plugin.py | 8 ++++++++ tests/plugins/my_plugin_2.py | 12 ++++++++++++ tests/test_plugins.py | 24 ++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 2 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 4b9807b0cd..3f2876ec2a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -798,7 +798,18 @@ async def route_path(self, scope, receive, send, path): 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 65c1c85946..71d06661fc 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 09e8f5e350..fb2843f425 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 10d7e7e6b1..305cb3b793 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -126,3 +126,11 @@ async def facet_results(self): 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 c9e7c78f1e..0a5cbba543 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -95,3 +95,15 @@ async def wrapped_send(event): 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 a34328a964..3ad2698686 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 From 9fc4c90f353bc7fab039ba0d07e5a124556cc877 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 30 May 2020 15:24:43 -0700 Subject: [PATCH 3/8] Implemented datasette.permission_allowed(), refs #699 --- datasette/app.py | 19 +++++++++++++++++++ docs/internals.rst | 19 +++++++++++++++++++ tests/plugins/my_plugin.py | 8 ++++++++ tests/plugins/my_plugin_2.py | 13 +++++++++++++ tests/test_plugins.py | 20 ++++++++++++++++---- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 3f2876ec2a..773dee311c 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -406,6 +406,25 @@ def _prepare_connection(self, conn, database): # pylint: disable=no-member pm.hook.prepare_connection(conn=conn, database=database, datasette=self) + async def permission_allowed( + self, actor, action, resource_type=None, resource_identifier=None, default=False + ): + "Check permissions using the permissions_allowed plugin hook" + for check in pm.hook.permission_allowed( + datasette=self, + actor=actor, + action=action, + resource_type=resource_type, + resource_identifier=resource_identifier, + ): + if callable(check): + check = check() + if asyncio.iscoroutine(check): + check = await check + if check is not None: + return check + return default + async def execute( self, db_name, diff --git a/docs/internals.rst b/docs/internals.rst index e9ba95672c..2ba70722e6 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -111,6 +111,25 @@ This method lets you read plugin configuration values that were set in ``metadat Renders a `Jinja template `__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins. +await .permission_allowed(actor, action, resource_type=None, resource_identifier=None, default=False) +----------------------------------------------------------------------------------------------------- + +``actor`` - dictionary + The authenticated actor. This is usually ``request.scope.get("actor")``. + +``action`` - string + The name of the action that is being permission checked. + +``resource_type`` - string, optional + The type of resource being checked, e.g. ``"table"``. + +``resource_identifier`` - string, optional + The resource identifier, e.g. the name of the table. + +Check if the given actor has permission to perform the given action on the given resource. This uses plugins that implement the :ref:`plugin_permission_allowed` plugin hook to decide if the action is allowed or not. + +If none of the plugins express an opinion, the return value will be the ``default`` argument. This is deny, but you can pass ``default=True`` to default allow instead. + .. _datasette_get_database: .get_database(name) diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 305cb3b793..4689371038 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -134,3 +134,11 @@ def actor_from_request(datasette, request): return {"id": "bot"} else: return None + + +@hookimpl +def permission_allowed(actor, action): + if action == "this_is_allowed": + return True + elif action == "this_is_denied": + return False diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index 0a5cbba543..039112f42e 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -107,3 +107,16 @@ async def inner(): return None return inner + + +@hookimpl +def permission_allowed(datasette, actor, action): + # Testing asyncio version of permission_allowed + async def inner(): + assert 2 == (await datasette.get_database().execute("select 1 + 1")).first()[0] + if action == "this_is_allowed_async": + return True + elif action == "this_is_denied_async": + return False + + return inner diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 3ad2698686..e123b7a020 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -523,7 +523,19 @@ def test_actor_from_request_async(app_client): 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 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action,expected", + [ + ("this_is_allowed", True), + ("this_is_denied", False), + ("this_is_allowed_async", True), + ("this_is_denied_async", False), + ("no_match", None), + ], +) +async def test_permission_allowed(app_client, action, expected): + actual = await app_client.ds.permission_allowed( + {"id": "actor"}, action, default=None + ) + assert expected == actual From 388eaaad6812954388e9e682005d394522df5ac5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 30 May 2020 18:51:00 -0700 Subject: [PATCH 4/8] Added /-/actor.json - refs #699 Also added JSON highlighting to introspection documentation. --- datasette/app.py | 7 ++++++ datasette/views/special.py | 8 +++++-- docs/introspection.rst | 44 ++++++++++++++++++++++++++++++++------ tests/test_plugins.py | 7 ++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 773dee311c..37b4ed3d5a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -586,6 +586,9 @@ def _threads(self): ) return d + def _actor(self, request): + return {"actor": request.scope.get("actor", None)} + def table_metadata(self, database, table): "Fetch table-specific metadata." return ( @@ -762,6 +765,10 @@ def add_route(view, regex): JsonDataView.as_asgi(self, "databases.json", self._connected_databases), r"/-/databases(?P(\.json)?)$", ) + add_route( + JsonDataView.as_asgi(self, "actor.json", self._actor, needs_request=True), + r"/-/actor(?P(\.json)?)$", + ) add_route( PatternPortfolioView.as_asgi(self), r"/-/patterns$", ) diff --git a/datasette/views/special.py b/datasette/views/special.py index dfe5ea8cac..840473a7d6 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -6,13 +6,17 @@ class JsonDataView(BaseView): name = "json_data" - def __init__(self, datasette, filename, data_callback): + def __init__(self, datasette, filename, data_callback, needs_request=False): self.ds = datasette self.filename = filename self.data_callback = data_callback + self.needs_request = needs_request async def get(self, request, as_format): - data = self.data_callback() + if self.needs_request: + data = self.data_callback(request) + else: + data = self.data_callback() if as_format: headers = {} if self.ds.cors: diff --git a/docs/introspection.rst b/docs/introspection.rst index 3cd4a40fd1..e5d08dbc05 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -10,7 +10,9 @@ Each of these pages can be viewed in your browser. Add ``.json`` to the URL to g /-/metadata ----------- -Shows the contents of the ``metadata.json`` file that was passed to ``datasette serve``, if any. `Metadata example `_:: +Shows the contents of the ``metadata.json`` file that was passed to ``datasette serve``, if any. `Metadata example `_: + +.. code-block:: json { "license": "CC Attribution 4.0 License", @@ -18,7 +20,9 @@ Shows the contents of the ``metadata.json`` file that was passed to ``datasette "source": "fivethirtyeight/data on GitHub", "source_url": "https://github.com/fivethirtyeight/data", "title": "Five Thirty Eight", - "databases": {...} + "databases": { + + } } .. _JsonDataView_versions: @@ -26,7 +30,9 @@ Shows the contents of the ``metadata.json`` file that was passed to ``datasette /-/versions ----------- -Shows the version of Datasette, Python and SQLite. `Versions example `_:: +Shows the version of Datasette, Python and SQLite. `Versions example `_: + +.. code-block:: json { "datasette": { @@ -63,7 +69,9 @@ Shows the version of Datasette, Python and SQLite. `Versions example `_:: +Shows a list of currently installed plugins and their versions. `Plugins example `_: + +.. code-block:: json [ { @@ -79,7 +87,9 @@ Shows a list of currently installed plugins and their versions. `Plugins example /-/config --------- -Shows the :ref:`config` options for this instance of Datasette. `Config example `_:: +Shows the :ref:`config` options for this instance of Datasette. `Config example `_: + +.. code-block:: json { "default_facet_size": 30, @@ -95,7 +105,9 @@ Shows the :ref:`config` options for this instance of Datasette. `Config example /-/databases ------------ -Shows currently attached databases. `Databases example `_:: +Shows currently attached databases. `Databases example `_: + +.. code-block:: json [ { @@ -113,7 +125,9 @@ Shows currently attached databases. `Databases example `_:: +Shows details of threads and ``asyncio`` tasks. `Threads example `_: + +.. code-block:: json { "num_threads": 2, @@ -136,3 +150,19 @@ Shows details of threads and ``asyncio`` tasks. `Threads example wait_for=()]>>" ] } + +.. _JsonDataView_actor: + +/-/actor +-------- + +Shows the currently authenticated actor. Useful for debugging Datasette authentication plugins. + +.. code-block:: json + + { + "actor": { + "id": 1, + "username": "some-user" + } + } diff --git a/tests/test_plugins.py b/tests/test_plugins.py index e123b7a020..7a3fb49af0 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -539,3 +539,10 @@ async def test_permission_allowed(app_client, action, expected): {"id": "actor"}, action, default=None ) assert expected == actual + + +def test_actor_json(app_client): + assert {"actor": None} == app_client.get("/-/actor.json").json + assert {"actor": {"id": "bot2", "1+1": 2}} == app_client.get( + "/-/actor.json/?_bot2=1" + ).json From e28207e76ec3b26b2c396370fd3fb325a60bfd49 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 May 2020 15:42:08 -0700 Subject: [PATCH 5/8] datasette.sign() and datasette.unsign() methods, refs #785 --- datasette/app.py | 9 +++++++++ datasette/cli.py | 7 +++++++ docs/datasette-serve-help.txt | 3 +++ docs/internals.rst | 28 ++++++++++++++++++++++++++++ setup.py | 1 + tests/test_cli.py | 1 + tests/test_internals_datasette.py | 12 ++++++++++++ 7 files changed, 61 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index 37b4ed3d5a..5e3d3af5df 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -14,6 +14,7 @@ import click from markupsafe import Markup +from itsdangerous import URLSafeSerializer import jinja2 from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape from jinja2.environment import Template @@ -163,12 +164,14 @@ def __init__( static_mounts=None, memory=False, config=None, + secret=None, version_note=None, config_dir=None, ): assert config_dir is None or isinstance( config_dir, Path ), "config_dir= should be a pathlib.Path" + self._secret = secret or os.urandom(32).hex() self.files = tuple(files) + tuple(immutables or []) if config_dir: self.files += tuple([str(p) for p in config_dir.glob("*.db")]) @@ -281,6 +284,12 @@ def __init__( self._register_renderers() + def sign(self, value, namespace="default"): + return URLSafeSerializer(self._secret, namespace).dumps(value) + + def unsign(self, signed, namespace="default"): + return URLSafeSerializer(self._secret, namespace).loads(signed) + def get_database(self, name=None): if name is None: return next(iter(self.databases.values())) diff --git a/datasette/cli.py b/datasette/cli.py index c59fb6e00b..dba3a6128e 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -299,6 +299,11 @@ def package( help="Set config option using configname:value datasette.readthedocs.io/en/latest/config.html", multiple=True, ) +@click.option( + "--secret", + help="Secret used for signing secure values, such as signed cookies", + envvar="DATASETTE_SECRET", +) @click.option("--version-note", help="Additional note to show on /-/versions") @click.option("--help-config", is_flag=True, help="Show available config options") def serve( @@ -317,6 +322,7 @@ def serve( static, memory, config, + secret, version_note, help_config, return_instance=False, @@ -362,6 +368,7 @@ def serve( static_mounts=static, config=dict(config), memory=memory, + secret=secret, version_note=version_note, ) diff --git a/docs/datasette-serve-help.txt b/docs/datasette-serve-help.txt index 5265c2946c..ab27714a0f 100644 --- a/docs/datasette-serve-help.txt +++ b/docs/datasette-serve-help.txt @@ -29,6 +29,9 @@ Options: --config CONFIG Set config option using configname:value datasette.readthedocs.io/en/latest/config.html + --secret TEXT Secret used for signing secure values, such as signed + cookies + --version-note TEXT Additional note to show on /-/versions --help-config Show available config options --help Show this message and exit. diff --git a/docs/internals.rst b/docs/internals.rst index 2ba70722e6..68a3531248 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -183,6 +183,34 @@ Use ``is_memory`` if the connection is to an in-memory SQLite database. This removes a database that has been previously added. ``name=`` is the unique name of that database, also used in the URL for it. +.. _datasette_sign: + +.sign(value, namespace="default") +--------------------------------- + +``value`` - any serializable type + The value to be signed. + +``namespace`` - string, optional + An alternative namespace, see the `itsdangerous salt documentation `__. + +Utility method for signing values, such that you can safely pass data to and from an untrusted environment. This is a wrapper around the `itsdangerous `__ library. + +This method returns a signed string, which can be decoded and verified using :ref:`datasette_unsign`. + +.. _datasette_unsign: + +.unsign(value, namespace="default") +----------------------------------- + +``signed`` - any serializable type + The signed string that was created using :ref:`datasette_sign`. + +``namespace`` - string, optional + The alternative namespace, if one was used. + +Returns the original, decoded object that was passed to :ref:`datasette_sign`. If the signature is not valid this raises a ``itsdangerous.BadSignature`` exception. + .. _internals_database: Database class diff --git a/setup.py b/setup.py index d9c70de521..936282660c 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ def get_version(): "janus>=0.4,<0.6", "PyYAML~=5.3", "mergedeep>=1.1.1,<1.4.0", + "itsdangerous~=1.1", ], entry_points=""" [console_scripts] diff --git a/tests/test_cli.py b/tests/test_cli.py index ac5746c6f4..f52f17b4e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -75,6 +75,7 @@ def test_metadata_yaml(): static=[], memory=False, config=[], + secret=None, version_note=None, help_config=False, return_instance=True, diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 4993250ddd..0be0b9328b 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -1,6 +1,7 @@ """ Tests for the datasette.app.Datasette class """ +from itsdangerous import BadSignature from .fixtures import app_client import pytest @@ -21,3 +22,14 @@ def test_get_database_no_argument(datasette): # Returns the first available database: db = datasette.get_database() assert "fixtures" == db.name + + +@pytest.mark.parametrize("value", ["hello", 123, {"key": "value"}]) +@pytest.mark.parametrize("namespace", [None, "two"]) +def test_sign_unsign(datasette, value, namespace): + extra_args = [namespace] if namespace else [] + signed = datasette.sign(value, *extra_args) + assert value != signed + assert value == datasette.unsign(signed, *extra_args) + with pytest.raises(BadSignature): + datasette.unsign(signed[:-1] + ("!" if signed[-1] != "!" else ":")) From c818de88a9c2683437875f788e325d911c8b767b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 May 2020 17:18:06 -0700 Subject: [PATCH 6/8] Docs for --secret/DATASETTE_SECRET - closes #785 --- docs/config.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/config.rst b/docs/config.rst index d8c2f55068..da93e40aff 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -288,3 +288,30 @@ For example, if you are sending traffic from ``https://www.example.com/tools/dat You can do that like so:: datasette mydatabase.db --config base_url:/tools/datasette/ + +.. _config_secret: + +Configuring the secret +---------------------- + +Datasette uses a secret string to sign secure values such as cookies. + +If you do not provide a secret, Datasette will create one when it starts up. This secret will reset every time the Datasette server restarts though, so things like authentication cookies will not stay valid between restarts. + +You can pass a secret to Datasette in two ways: with the ``--secret`` command-line option or by setting a ``DATASETTE_SECRET`` environment variable. + +:: + + $ datasette mydb.db --secret=SECRET_VALUE_HERE + +Or:: + + $ export DATASETTE_SECRET=SECRET_VALUE_HERE + $ datasette mydb.db + +One way to generate a secure random secret is to use Python like this:: + + $ python3 -c 'import os; print(os.urandom(32).hex())' + cdb19e94283a20f9d42cca50c5a4871c0aa07392db308755d60a1a5b9bb0fa52 + +Plugin authors make use of this signing mechanism in their plugins using :ref:`datasette_sign` and :ref:`datasette_unsign`. From cc6421591fe3da1373d472524cafd8911a09acc0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 May 2020 18:03:17 -0700 Subject: [PATCH 7/8] --root option and /-/auth-token view, refs #784 --- datasette/app.py | 6 +++++- datasette/cli.py | 8 ++++++++ datasette/plugins.py | 1 + datasette/views/special.py | 32 +++++++++++++++++++++++++++++++- docs/datasette-serve-help.txt | 1 + tests/fixtures.py | 19 +++++++++++++++---- tests/test_auth.py | 25 +++++++++++++++++++++++++ tests/test_cli.py | 1 + tests/test_docs.py | 4 ++-- 9 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 tests/test_auth.py diff --git a/datasette/app.py b/datasette/app.py index 5e3d3af5df..6b39ce1222 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -24,7 +24,7 @@ from .views.base import DatasetteError, ureg, AsgiRouter from .views.database import DatabaseDownload, DatabaseView from .views.index import IndexView -from .views.special import JsonDataView, PatternPortfolioView +from .views.special import JsonDataView, PatternPortfolioView, AuthTokenView from .views.table import RowView, TableView from .renderer import json_renderer from .database import Database, QueryInterrupted @@ -283,6 +283,7 @@ def __init__( pm.hook.prepare_jinja2_environment(env=self.jinja_env) self._register_renderers() + self._root_token = os.urandom(32).hex() def sign(self, value, namespace="default"): return URLSafeSerializer(self._secret, namespace).dumps(value) @@ -778,6 +779,9 @@ def add_route(view, regex): JsonDataView.as_asgi(self, "actor.json", self._actor, needs_request=True), r"/-/actor(?P(\.json)?)$", ) + add_route( + AuthTokenView.as_asgi(self), r"/-/auth-token$", + ) add_route( PatternPortfolioView.as_asgi(self), r"/-/patterns$", ) diff --git a/datasette/cli.py b/datasette/cli.py index dba3a6128e..23f9e36b9d 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -304,6 +304,11 @@ def package( help="Secret used for signing secure values, such as signed cookies", envvar="DATASETTE_SECRET", ) +@click.option( + "--root", + help="Output URL that sets a cookie authenticating the root user", + is_flag=True, +) @click.option("--version-note", help="Additional note to show on /-/versions") @click.option("--help-config", is_flag=True, help="Show available config options") def serve( @@ -323,6 +328,7 @@ def serve( memory, config, secret, + root, version_note, help_config, return_instance=False, @@ -387,6 +393,8 @@ def serve( asyncio.get_event_loop().run_until_complete(check_databases(ds)) # Start the server + if root: + print("http://{}:{}/-/auth-token?token={}".format(host, port, ds._root_token)) uvicorn.run(ds.app(), host=host, port=port, log_level="info") diff --git a/datasette/plugins.py b/datasette/plugins.py index 6c9677d066..487fce4d97 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -9,6 +9,7 @@ "datasette.publish.cloudrun", "datasette.facets", "datasette.sql_functions", + "datasette.actor_auth_cookie", ) pm = pluggy.PluginManager("datasette") diff --git a/datasette/views/special.py b/datasette/views/special.py index 840473a7d6..910193e867 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,6 +1,8 @@ import json from datasette.utils.asgi import Response from .base import BaseView +from http.cookies import SimpleCookie +import secrets class JsonDataView(BaseView): @@ -45,4 +47,32 @@ def __init__(self, datasette): self.ds = datasette async def get(self, request): - return await self.render(["patterns.html"], request=request,) + return await self.render(["patterns.html"], request=request) + + +class AuthTokenView(BaseView): + name = "auth_token" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + token = request.args.get("token") or "" + if not self.ds._root_token: + return Response("Root token has already been used", status=403) + if secrets.compare_digest(token, self.ds._root_token): + self.ds._root_token = None + cookie = SimpleCookie() + cookie["ds_actor"] = self.ds.sign({"id": "root"}, "actor") + cookie["ds_actor"]["path"] = "/" + response = Response( + body="", + status=302, + headers={ + "Location": "/", + "set-cookie": cookie.output(header="").lstrip(), + }, + ) + return response + else: + return Response("Invalid token", status=403) diff --git a/docs/datasette-serve-help.txt b/docs/datasette-serve-help.txt index ab27714a0f..183ecc1465 100644 --- a/docs/datasette-serve-help.txt +++ b/docs/datasette-serve-help.txt @@ -32,6 +32,7 @@ Options: --secret TEXT Secret used for signing secure values, such as signed cookies + --root Output URL that sets a cookie authenticating the root user --version-note TEXT Additional note to show on /-/versions --help-config Show available config options --help Show this message and exit. diff --git a/tests/fixtures.py b/tests/fixtures.py index 9479abf607..b2cfd3d659 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -2,6 +2,7 @@ from datasette.utils import sqlite3 from asgiref.testing import ApplicationCommunicator from asgiref.sync import async_to_sync +from http.cookies import SimpleCookie import itertools import json import os @@ -44,10 +45,14 @@ def __init__(self, asgi_app): self.asgi_app = asgi_app @async_to_sync - async def get(self, path, allow_redirects=True, redirect_count=0, method="GET"): - return await self._get(path, allow_redirects, redirect_count, method) + async def get( + self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None + ): + return await self._get(path, allow_redirects, redirect_count, method, cookies) - async def _get(self, path, allow_redirects=True, redirect_count=0, method="GET"): + async def _get( + self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None + ): query_string = b"" if "?" in path: path, _, query_string = path.partition("?") @@ -56,6 +61,12 @@ async def _get(self, path, allow_redirects=True, redirect_count=0, method="GET") raw_path = path.encode("latin-1") else: raw_path = quote(path, safe="/:,").encode("latin-1") + headers = [[b"host", b"localhost"]] + if cookies: + sc = SimpleCookie() + for key, value in cookies.items(): + sc[key] = value + headers.append([b"cookie", sc.output(header="").encode("utf-8")]) scope = { "type": "http", "http_version": "1.0", @@ -63,7 +74,7 @@ async def _get(self, path, allow_redirects=True, redirect_count=0, method="GET") "path": unquote(path), "raw_path": raw_path, "query_string": query_string, - "headers": [[b"host", b"localhost"]], + "headers": headers, } instance = ApplicationCommunicator(self.asgi_app, scope) await instance.send_input({"type": "http.request"}) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000000..6b69ab9383 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,25 @@ +from .fixtures import app_client + + +def test_auth_token(app_client): + "The /-/auth-token endpoint sets the correct cookie" + assert app_client.ds._root_token is not None + path = "/-/auth-token?token={}".format(app_client.ds._root_token) + response = app_client.get(path, allow_redirects=False,) + assert 302 == response.status + assert "/" == response.headers["Location"] + set_cookie = response.headers["set-cookie"] + assert set_cookie.endswith("; Path=/") + assert set_cookie.startswith("ds_actor=") + cookie_value = set_cookie.split("ds_actor=")[1].split("; Path=/")[0] + assert {"id": "root"} == app_client.ds.unsign(cookie_value, "actor") + # Check that a second with same token fails + assert app_client.ds._root_token is None + assert 403 == app_client.get(path, allow_redirects=False,).status + + +def test_actor_cookie(app_client): + "A valid actor cookie sets request.scope['actor']" + cookie = app_client.ds.sign({"id": "test"}, "actor") + response = app_client.get("/", cookies={"ds_actor": cookie}) + assert {"id": "test"} == app_client.ds._last_request.scope["actor"] diff --git a/tests/test_cli.py b/tests/test_cli.py index f52f17b4e8..529661ced9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -76,6 +76,7 @@ def test_metadata_yaml(): memory=False, config=[], secret=None, + root=False, version_note=None, help_config=False, return_instance=True, diff --git a/tests/test_docs.py b/tests/test_docs.py index 77c2a61195..09c00ddf95 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -65,8 +65,8 @@ def documented_views(): first_word = label.split("_")[0] if first_word.endswith("View"): view_labels.add(first_word) - # We deliberately don't document this one: - view_labels.add("PatternPortfolioView") + # We deliberately don't document these: + view_labels.update(("PatternPortfolioView", "AuthTokenView")) return view_labels From 116a26ce2d4af26c928e361e1b24dd6a6bb14eca Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 May 2020 18:06:16 -0700 Subject: [PATCH 8/8] Default actor_from_request hook supporting ds_actor signed cookie Refs #784, refs #699 --- datasette/actor_auth_cookie.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 datasette/actor_auth_cookie.py diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py new file mode 100644 index 0000000000..41f33fe98a --- /dev/null +++ b/datasette/actor_auth_cookie.py @@ -0,0 +1,18 @@ +from datasette import hookimpl +from itsdangerous import BadSignature +from http.cookies import SimpleCookie + + +@hookimpl +def actor_from_request(datasette, request): + cookies = SimpleCookie() + cookies.load( + dict(request.scope.get("headers") or []).get(b"cookie", b"").decode("utf-8") + ) + if "ds_actor" not in cookies: + return None + ds_actor = cookies["ds_actor"].value + try: + return datasette.unsign(ds_actor, "actor") + except BadSignature: + return None