Added /-/permissions debug tool, closes #788

Also started the authentication.rst docs page, refs #786.

Part of authentication work, refs #699.
This commit is contained in:
Simon Willison 2020-05-31 22:00:36 -07:00
commit dfdbdf378a
8 changed files with 152 additions and 3 deletions

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import collections import collections
import datetime
import hashlib import hashlib
import itertools import itertools
import json import json
@ -24,7 +25,12 @@ import uvicorn
from .views.base import DatasetteError, ureg, AsgiRouter from .views.base import DatasetteError, ureg, AsgiRouter
from .views.database import DatabaseDownload, DatabaseView from .views.database import DatabaseDownload, DatabaseView
from .views.index import IndexView 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 .views.table import RowView, TableView
from .renderer import json_renderer from .renderer import json_renderer
from .database import Database, QueryInterrupted from .database import Database, QueryInterrupted
@ -283,6 +289,7 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env) pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self._register_renderers() self._register_renderers()
self.permission_checks = collections.deque(maxlen=30)
self._root_token = os.urandom(32).hex() self._root_token = os.urandom(32).hex()
def sign(self, value, namespace="default"): def sign(self, value, namespace="default"):
@ -420,6 +427,7 @@ class Datasette:
self, actor, action, resource_type=None, resource_identifier=None, default=False self, actor, action, resource_type=None, resource_identifier=None, default=False
): ):
"Check permissions using the permissions_allowed plugin hook" "Check permissions using the permissions_allowed plugin hook"
result = None
for check in pm.hook.permission_allowed( for check in pm.hook.permission_allowed(
datasette=self, datasette=self,
actor=actor, actor=actor,
@ -432,8 +440,23 @@ class Datasette:
if asyncio.iscoroutine(check): if asyncio.iscoroutine(check):
check = await check check = await check
if check is not None: if check is not None:
return check result = check
return default 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( async def execute(
self, self,
@ -782,6 +805,9 @@ class Datasette:
add_route( add_route(
AuthTokenView.as_asgi(self), r"/-/auth-token$", AuthTokenView.as_asgi(self), r"/-/auth-token$",
) )
add_route(
PermissionsDebugView.as_asgi(self), r"/-/permissions$",
)
add_route( add_route(
PatternPortfolioView.as_asgi(self), r"/-/patterns$", PatternPortfolioView.as_asgi(self), r"/-/patterns$",
) )

View file

@ -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

View file

@ -10,6 +10,7 @@ DEFAULT_PLUGINS = (
"datasette.facets", "datasette.facets",
"datasette.sql_functions", "datasette.sql_functions",
"datasette.actor_auth_cookie", "datasette.actor_auth_cookie",
"datasette.default_permissions",
) )
pm = pluggy.PluginManager("datasette") pm = pluggy.PluginManager("datasette")

View file

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Debug permissions{% endblock %}
{% block extra_head %}
<style type="text/css">
.check-result-true {
color: green;
}
.check-result-false {
color: red;
}
.check h2 {
font-size: 1em
}
.check-action, .check-when, .check-result {
font-size: 1.3em;
}
</style>
{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ base_url }}">home</a>
</p>
{{ super() }}
{% endblock %}
{% block content %}
<h1>Recent permissions checks</h1>
{% for check in permission_checks %}
<div class="check">
<h2>
<span class="check-action">{{ check.action }}</span>
checked at
<span class="check-when">{{ check.when }}</span>
{% if check.result %}
<span class="check-result check-result-true"></span>
{% else %}
<span class="check-result check-result-false"></span>
{% endif %}
{% if check.used_default %}
<span class="check-used-default">(used default)</span>
{% endif %}
</h2>
<p><strong>Actor:</strong> {{ check.actor|tojson }}</p>
{% if check.resource_type %}
<p><strong>Resource:</strong> {{ check.resource_type }}: {{ check.resource_identifier }}</p>
{% endif %}
</div>
{% endfor %}
{% endblock %}

View file

@ -76,3 +76,21 @@ class AuthTokenView(BaseView):
return response return response
else: else:
return Response("Invalid token", status=403) 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)},
)

18
docs/authentication.rst Normal file
View file

@ -0,0 +1,18 @@
.. _authentication:
================================
Authentication and permissions
================================
Datasette's authentication system is currently under construction. Follow `issue 699 <https://github.com/simonw/datasette/issues/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.

View file

@ -40,6 +40,7 @@ Contents
publish publish
json_api json_api
sql_queries sql_queries
authentication
performance performance
csv_export csv_export
facets facets

View file

@ -1,4 +1,5 @@
from .fixtures import app_client from .fixtures import app_client
from bs4 import BeautifulSoup as Soup
def test_auth_token(app_client): def test_auth_token(app_client):
@ -23,3 +24,25 @@ def test_actor_cookie(app_client):
cookie = app_client.ds.sign({"id": "test"}, "actor") cookie = app_client.ds.sign({"id": "test"}, "actor")
response = app_client.get("/", cookies={"ds_actor": cookie}) response = app_client.get("/", cookies={"ds_actor": cookie})
assert {"id": "test"} == app_client.ds._last_request.scope["actor"] 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