From dfdbdf378aba9afb66666f66b78df2f2069d2595 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 May 2020 22:00:36 -0700 Subject: [PATCH] Added /-/permissions debug tool, closes #788 Also started the authentication.rst docs page, refs #786. Part of authentication work, refs #699. --- datasette/app.py | 32 +++++++++++-- datasette/default_permissions.py | 7 +++ datasette/plugins.py | 1 + datasette/templates/permissions_debug.html | 55 ++++++++++++++++++++++ datasette/views/special.py | 18 +++++++ docs/authentication.rst | 18 +++++++ docs/index.rst | 1 + tests/test_auth.py | 23 +++++++++ 8 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 datasette/default_permissions.py create mode 100644 datasette/templates/permissions_debug.html create mode 100644 docs/authentication.rst diff --git a/datasette/app.py b/datasette/app.py index 6b39ce12..b8a5e23d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,5 +1,6 @@ import asyncio import collections +import datetime import hashlib import itertools import json @@ -24,7 +25,12 @@ import uvicorn from .views.base import DatasetteError, ureg, AsgiRouter from .views.database import DatabaseDownload, DatabaseView from .views.index import IndexView -from .views.special import JsonDataView, PatternPortfolioView, AuthTokenView +from .views.special import ( + JsonDataView, + PatternPortfolioView, + AuthTokenView, + PermissionsDebugView, +) from .views.table import RowView, TableView from .renderer import json_renderer from .database import Database, QueryInterrupted @@ -283,6 +289,7 @@ class Datasette: pm.hook.prepare_jinja2_environment(env=self.jinja_env) self._register_renderers() + self.permission_checks = collections.deque(maxlen=30) self._root_token = os.urandom(32).hex() def sign(self, value, namespace="default"): @@ -420,6 +427,7 @@ class Datasette: self, actor, action, resource_type=None, resource_identifier=None, default=False ): "Check permissions using the permissions_allowed plugin hook" + result = None for check in pm.hook.permission_allowed( datasette=self, actor=actor, @@ -432,8 +440,23 @@ class Datasette: if asyncio.iscoroutine(check): check = await check if check is not None: - return check - return default + result = check + used_default = False + if result is None: + result = default + used_default = True + self.permission_checks.append( + { + "when": datetime.datetime.utcnow().isoformat(), + "actor": actor, + "action": action, + "resource_type": resource_type, + "resource_identifier": resource_identifier, + "used_default": used_default, + "result": result, + } + ) + return result async def execute( self, @@ -782,6 +805,9 @@ class Datasette: add_route( AuthTokenView.as_asgi(self), r"/-/auth-token$", ) + add_route( + PermissionsDebugView.as_asgi(self), r"/-/permissions$", + ) add_route( PatternPortfolioView.as_asgi(self), r"/-/patterns$", ) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py new file mode 100644 index 00000000..0b0d17f9 --- /dev/null +++ b/datasette/default_permissions.py @@ -0,0 +1,7 @@ +from datasette import hookimpl + + +@hookimpl +def permission_allowed(actor, action, resource_type, resource_identifier): + if actor and actor.get("id") == "root" and action == "permissions-debug": + return True diff --git a/datasette/plugins.py b/datasette/plugins.py index 487fce4d..26d4fd63 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -10,6 +10,7 @@ DEFAULT_PLUGINS = ( "datasette.facets", "datasette.sql_functions", "datasette.actor_auth_cookie", + "datasette.default_permissions", ) pm = pluggy.PluginManager("datasette") diff --git a/datasette/templates/permissions_debug.html b/datasette/templates/permissions_debug.html new file mode 100644 index 00000000..fb098c5c --- /dev/null +++ b/datasette/templates/permissions_debug.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Debug permissions{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block nav %} +

+ home +

+ {{ super() }} +{% endblock %} + +{% block content %} + +

Recent permissions checks

+ +{% for check in permission_checks %} +
+

+ {{ check.action }} + checked at + {{ check.when }} + {% if check.result %} + + {% else %} + + {% endif %} + {% if check.used_default %} + (used default) + {% endif %} +

+

Actor: {{ check.actor|tojson }}

+ {% if check.resource_type %} +

Resource: {{ check.resource_type }}: {{ check.resource_identifier }}

+ {% endif %} +
+{% endfor %} + +{% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index 910193e8..b75355fb 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -76,3 +76,21 @@ class AuthTokenView(BaseView): return response else: return Response("Invalid token", status=403) + + +class PermissionsDebugView(BaseView): + name = "permissions_debug" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + if not await self.ds.permission_allowed( + request.scope.get("actor"), "permissions-debug" + ): + return Response("Permission denied", status=403) + return await self.render( + ["permissions_debug.html"], + request, + {"permission_checks": reversed(self.ds.permission_checks)}, + ) diff --git a/docs/authentication.rst b/docs/authentication.rst new file mode 100644 index 00000000..0a9a4c0d --- /dev/null +++ b/docs/authentication.rst @@ -0,0 +1,18 @@ +.. _authentication: + +================================ + Authentication and permissions +================================ + +Datasette's authentication system is currently under construction. Follow `issue 699 `__ to track the development of this feature. + +.. _PermissionsDebugView: + +Permissions Debug +================= + +The debug tool at ``/-/permissions`` is only available to the root user. + +It shows the thirty most recent permission checks that have been carried out by the Datasette instance. + +This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system. diff --git a/docs/index.rst b/docs/index.rst index 2390e263..03988c8e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ Contents publish json_api sql_queries + authentication performance csv_export facets diff --git a/tests/test_auth.py b/tests/test_auth.py index 6b69ab93..ddf328af 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,5 @@ from .fixtures import app_client +from bs4 import BeautifulSoup as Soup def test_auth_token(app_client): @@ -23,3 +24,25 @@ def test_actor_cookie(app_client): 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"] + + +def test_permissions_debug(app_client): + assert 403 == app_client.get("/-/permissions").status + # With the cookie it should work + cookie = app_client.ds.sign({"id": "root"}, "actor") + response = app_client.get("/-/permissions", cookies={"ds_actor": cookie}) + # Should show one failure and one success + soup = Soup(response.body, "html.parser") + check_divs = soup.findAll("div", {"class": "check"}) + checks = [ + { + "action": div.select_one(".check-action").text, + "result": bool(div.select(".check-result-true")), + "used_default": bool(div.select(".check-used-default")), + } + for div in check_divs + ] + assert [ + {"action": "permissions-debug", "result": True, "used_default": False}, + {"action": "permissions-debug", "result": False, "used_default": True}, + ] == checks