diff --git a/datasette/app.py b/datasette/app.py index 5fa4955c..9df16558 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,5 +1,5 @@ import asyncio -from typing import Sequence, Union, Tuple +from typing import Sequence, Union, Tuple, Optional import asgi_csrf import collections import datetime @@ -707,7 +707,7 @@ class Datasette: Raises datasette.Forbidden() if any of the checks fail """ - assert actor is None or isinstance(actor, dict) + assert actor is None or isinstance(actor, dict), "actor must be None or a dict" for permission in permissions: if isinstance(permission, str): action = permission @@ -732,23 +732,34 @@ class Datasette: else: raise Forbidden(action) - async def check_visibility(self, actor, action, resource): + async def check_visibility( + self, + actor: dict, + action: Optional[str] = None, + resource: Optional[Union[str, Tuple[str, str]]] = None, + permissions: Optional[ + Sequence[Union[Tuple[str, Union[str, Tuple[str, str]]], str]] + ] = None, + ): """Returns (visible, private) - visible = can you see it, private = can others see it too""" - visible = await self.permission_allowed( - actor, - action, - resource=resource, - default=True, - ) - if not visible: + if permissions: + assert ( + not action and not resource + ), "Can't use action= or resource= with permissions=" + else: + permissions = [(action, resource)] + try: + await self.ensure_permissions(actor, permissions) + except Forbidden: return False, False - private = not await self.permission_allowed( - None, - action, - resource=resource, - default=True, - ) - return visible, private + # User can see it, but can the anonymous user see it? + try: + await self.ensure_permissions(None, permissions) + except Forbidden: + # It's visible but private + return True, True + # It's visible to everyone + return True, False async def execute( self, diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 554997e5..1d1b0bfd 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -20,7 +20,7 @@ {% endblock %} {% block content %} -

{{ table }}: {{ ', '.join(primary_key_values) }}

+

{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}

{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index affbc540..bd9e4a7c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -40,13 +40,16 @@ class DatabaseView(DataView): raise NotFound("Database not found: {}".format(database_route)) database = db.name - await self.ds.ensure_permissions( + visible, private = await self.ds.check_visibility( request.actor, - [ + permissions=[ ("view-database", database), "view-instance", ], ) + if not visible: + raise Forbidden("You do not have permission to view this database") + metadata = (self.ds.metadata("databases") or {}).get(database, {}) self.ds.update_with_inherited_metadata(metadata) @@ -63,27 +66,27 @@ class DatabaseView(DataView): views = [] for view_name in await db.view_names(): - visible, private = await self.ds.check_visibility( + view_visible, view_private = await self.ds.check_visibility( request.actor, "view-table", (database, view_name), ) - if visible: + if view_visible: views.append( { "name": view_name, - "private": private, + "private": view_private, } ) tables = [] for table in table_counts: - visible, private = await self.ds.check_visibility( + table_visible, table_private = await self.ds.check_visibility( request.actor, "view-table", (database, table), ) - if not visible: + if not table_visible: continue table_columns = await db.table_columns(table) tables.append( @@ -95,7 +98,7 @@ class DatabaseView(DataView): "hidden": table in hidden_table_names, "fts_table": await db.fts_table(table), "foreign_keys": all_foreign_keys[table], - "private": private, + "private": table_private, } ) @@ -104,13 +107,13 @@ class DatabaseView(DataView): for query in ( await self.ds.get_canned_queries(database, request.actor) ).values(): - visible, private = await self.ds.check_visibility( + query_visible, query_private = await self.ds.check_visibility( request.actor, "view-query", (database, query["name"]), ) - if visible: - canned_queries.append(dict(query, private=private)) + if query_visible: + canned_queries.append(dict(query, private=query_private)) async def database_actions(): links = [] @@ -130,15 +133,13 @@ class DatabaseView(DataView): return ( { "database": database, + "private": private, "path": self.ds.urls.database(database), "size": db.size, "tables": tables, "hidden_count": len([t for t in tables if t["hidden"]]), "views": views, "queries": canned_queries, - "private": not await self.ds.permission_allowed( - None, "view-database", database, default=True - ), "allow_execute_sql": await self.ds.permission_allowed( request.actor, "execute-sql", database, default=True ), @@ -227,17 +228,17 @@ class QueryView(DataView): private = False if canned_query: # Respect canned query permissions - await self.ds.ensure_permissions( + visible, private = await self.ds.check_visibility( request.actor, - [ + permissions=[ ("view-query", (database, canned_query)), ("view-database", database), "view-instance", ], ) - private = not await self.ds.permission_allowed( - None, "view-query", (database, canned_query), default=True - ) + if not visible: + raise Forbidden("You do not have permission to view this query") + else: await self.ds.ensure_permissions(request.actor, [("execute-sql", database)]) diff --git a/datasette/views/index.py b/datasette/views/index.py index aec78814..1f366a49 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -23,25 +23,25 @@ class IndexView(BaseView): await self.ds.ensure_permissions(request.actor, ["view-instance"]) databases = [] for name, db in self.ds.databases.items(): - visible, database_private = await self.ds.check_visibility( + database_visible, database_private = await self.ds.check_visibility( request.actor, "view-database", name, ) - if not visible: + if not database_visible: continue table_names = await db.table_names() hidden_table_names = set(await db.hidden_table_names()) views = [] for view_name in await db.view_names(): - visible, private = await self.ds.check_visibility( + view_visible, view_private = await self.ds.check_visibility( request.actor, "view-table", (name, view_name), ) - if visible: - views.append({"name": view_name, "private": private}) + if view_visible: + views.append({"name": view_name, "private": view_private}) # Perform counts only for immutable or DBS with <= COUNT_TABLE_LIMIT tables table_counts = {} diff --git a/datasette/views/row.py b/datasette/views/row.py index b1c7362d..cdbf0990 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -1,4 +1,4 @@ -from datasette.utils.asgi import NotFound +from datasette.utils.asgi import NotFound, Forbidden from datasette.database import QueryInterrupted from .base import DataView from datasette.utils import ( @@ -21,14 +21,19 @@ class RowView(DataView): except KeyError: raise NotFound("Database not found: {}".format(database_route)) database = db.name - await self.ds.ensure_permissions( + + # Ensure user has permission to view this row + visible, private = await self.ds.check_visibility( request.actor, - [ + permissions=[ ("view-table", (database, table)), ("view-database", database), "view-instance", ], ) + if not visible: + raise Forbidden("You do not have permission to view this table") + pk_values = urlsafe_components(request.url_vars["pks"]) try: db = self.ds.get_database(route=database_route) @@ -55,6 +60,7 @@ class RowView(DataView): for column in display_columns: column["sortable"] = False return { + "private": private, "foreign_key_tables": await self.foreign_key_tables( database, table, pk_values ), diff --git a/datasette/views/table.py b/datasette/views/table.py index 60c092f9..f73b0957 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -28,7 +28,7 @@ from datasette.utils import ( urlsafe_components, value_as_boolean, ) -from datasette.utils.asgi import BadRequest, NotFound +from datasette.utils.asgi import BadRequest, Forbidden, NotFound from datasette.filters import Filters from .base import DataView, DatasetteError, ureg from .database import QueryView @@ -213,18 +213,16 @@ class TableView(DataView): raise NotFound(f"Table not found: {table_name}") # Ensure user has permission to view this table - await self.ds.ensure_permissions( + visible, private = await self.ds.check_visibility( request.actor, - [ + permissions=[ ("view-table", (database_name, table_name)), ("view-database", database_name), "view-instance", ], ) - - private = not await self.ds.permission_allowed( - None, "view-table", (database_name, table_name), default=True - ) + if not visible: + raise Forbidden("You do not have permission to view this table") # Handle ?_filter_column and redirect, if present redirect_params = filters_should_redirect(request.args) diff --git a/docs/internals.rst b/docs/internals.rst index adeec1d8..92f4efee 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -349,7 +349,7 @@ await .ensure_permissions(actor, permissions) ``permissions`` - list A list of permissions to check. Each permission in that list can be a string ``action`` name or a 2-tuple of ``(action, resource)``. -This method allows multiple permissions to be checked at onced. It raises a ``datasette.Forbidden`` exception if any of the checks are denied before one of them is explicitly granted. +This method allows multiple permissions to be checked at once. It raises a ``datasette.Forbidden`` exception if any of the checks are denied before one of them is explicitly granted. This is useful when you need to check multiple permissions at once. For example, an actor should be able to view a table if either one of the following checks returns ``True`` or not a single one of them returns ``False``: @@ -366,18 +366,21 @@ This is useful when you need to check multiple permissions at once. For example, .. _datasette_check_visibilty: -await .check_visibility(actor, action, resource=None) ------------------------------------------------------ +await .check_visibility(actor, action=None, resource=None, permissions=None) +---------------------------------------------------------------------------- ``actor`` - dictionary The authenticated actor. This is usually ``request.actor``. -``action`` - string +``action`` - string, optional The name of the action that is being permission checked. ``resource`` - string or tuple, optional The resource, e.g. the name of the database, or a tuple of two strings containing the name of the database and the name of the table. Only some permissions apply to a resource. +``permissions`` - list of ``action`` strings or ``(action, resource)`` tuples, optional + Provide this instead of ``action`` and ``resource`` to check multiple permissions at once. + This convenience method can be used to answer the question "should this item be considered private, in that it is visible to me but it is not visible to anonymous users?" It returns a tuple of two booleans, ``(visible, private)``. ``visible`` indicates if the actor can see this resource. ``private`` will be ``True`` if an anonymous user would not be able to view the resource. @@ -387,7 +390,22 @@ This example checks if the user can access a specific table, and sets ``private` .. code-block:: python visible, private = await self.ds.check_visibility( - request.actor, "view-table", (database, table) + request.actor, + action="view-table", + resource=(database, table), + ) + +The following example runs three checks in a row, similar to :ref:`datasette_ensure_permissions`. If any of the checks are denied before one of them is explicitly granted then ``visible`` will be ``False``. ``private`` will be ``True`` if an anonymous user would not be able to view the resource. + +.. code-block:: python + + visible, private = await self.ds.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, table)), + ("view-database", database), + "view-instance", + ], ) .. _datasette_get_database: diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 249920fe..c82cafb3 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 datasette import Forbidden from datasette.app import Datasette, Database from itsdangerous import BadSignature from .fixtures import app_client @@ -75,3 +76,52 @@ async def test_num_sql_threads_zero(): assert response.json() == {"num_threads": 0, "threads": []} response2 = await ds.client.get("/test_num_sql_threads_zero/t.json?_shape=array") assert response2.json() == [{"id": 1}] + + +ROOT = {"id": "root"} +ALLOW_ROOT = {"allow": {"id": "root"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "actor,metadata,permissions,should_allow,expected_private", + ( + (None, ALLOW_ROOT, ["view-instance"], False, False), + (ROOT, ALLOW_ROOT, ["view-instance"], True, True), + ( + None, + {"databases": {"_memory": ALLOW_ROOT}}, + [("view-database", "_memory")], + False, + False, + ), + ( + ROOT, + {"databases": {"_memory": ALLOW_ROOT}}, + [("view-database", "_memory")], + True, + True, + ), + # Check private is false for non-protected instance check + ( + ROOT, + {"allow": True}, + ["view-instance"], + True, + False, + ), + ), +) +async def test_datasette_ensure_permissions_check_visibility( + actor, metadata, permissions, should_allow, expected_private +): + ds = Datasette([], memory=True, metadata=metadata) + if not should_allow: + with pytest.raises(Forbidden): + await ds.ensure_permissions(actor, permissions) + else: + await ds.ensure_permissions(actor, permissions) + # And try check_visibility too: + visible, private = await ds.check_visibility(actor, permissions=permissions) + assert visible == should_allow + assert private == expected_private diff --git a/tests/test_permissions.py b/tests/test_permissions.py index dadaf810..2d48431a 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -5,6 +5,20 @@ import pytest import urllib +@pytest.fixture(scope="module") +def padlock_client(): + with make_app_client( + metadata={ + "databases": { + "fixtures": { + "queries": {"two": {"sql": "select 1 + 1"}}, + } + } + } + ) as client: + yield client + + @pytest.mark.parametrize( "allow,expected_anon,expected_auth", [ @@ -13,27 +27,33 @@ import urllib ({"id": "root"}, 403, 200), ], ) -def test_view_instance(allow, expected_anon, expected_auth): - with make_app_client(metadata={"allow": allow}) as client: - for path in ( - "/", - "/fixtures", - "/fixtures/compound_three_primary_keys", - "/fixtures/compound_three_primary_keys/a,a,a", - ): - anon_response = client.get(path) - assert expected_anon == anon_response.status - if allow and path == "/" and anon_response.status == 200: - # Should be no padlock - assert "

Datasette 🔒

" not in anon_response.text - auth_response = client.get( - path, - cookies={"ds_actor": client.actor_cookie({"id": "root"})}, - ) - assert expected_auth == auth_response.status - # Check for the padlock - if allow and path == "/" and expected_anon == 403 and expected_auth == 200: - assert "

Datasette 🔒

" in auth_response.text +@pytest.mark.parametrize( + "path", + ( + "/", + "/fixtures", + "/fixtures/compound_three_primary_keys", + "/fixtures/compound_three_primary_keys/a,a,a", + "/fixtures/two", # Query + ), +) +def test_view_padlock(allow, expected_anon, expected_auth, path, padlock_client): + padlock_client.ds._metadata_local["allow"] = allow + fragment = "🔒" + anon_response = padlock_client.get(path) + assert expected_anon == anon_response.status + if allow and anon_response.status == 200: + # Should be no padlock + assert fragment not in anon_response.text + auth_response = padlock_client.get( + path, + cookies={"ds_actor": padlock_client.actor_cookie({"id": "root"})}, + ) + assert expected_auth == auth_response.status + # Check for the padlock + if allow and expected_anon == 403 and expected_auth == 200: + assert fragment in auth_response.text + del padlock_client.ds._metadata_local["allow"] @pytest.mark.parametrize( @@ -467,6 +487,10 @@ def test_permissions_cascade(cascade_app_client, path, permissions, expected_sta path, cookies={"ds_actor": cascade_app_client.actor_cookie(actor)}, ) - assert expected_status == response.status + assert ( + response.status == expected_status + ), "path: {}, permissions: {}, expected_status: {}, status: {}".format( + path, permissions, expected_status, response.status + ) finally: - cascade_app_client.ds._metadata_local = previous_metadata + cascade_app_client.ds._local_metadata = previous_metadata diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 02cac132..e0a7bc76 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -823,8 +823,14 @@ def test_hook_forbidden(restore_working_directory): assert 403 == response.status response2 = client.get("/data2") assert 302 == response2.status - assert "/login?message=view-database" == response2.headers["Location"] - assert "view-database" == client.ds._last_forbidden_message + assert ( + response2.headers["Location"] + == "/login?message=You do not have permission to view this database" + ) + assert ( + client.ds._last_forbidden_message + == "You do not have permission to view this database" + ) def test_hook_handle_exception(app_client):