Merge pull request #803 from simonw/canned-query-permissions

This commit is contained in:
Simon Willison 2020-06-06 12:40:19 -07:00 committed by GitHub
commit 415ccd7cbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 247 additions and 9 deletions

View file

@ -60,7 +60,7 @@
<h2 id="queries">Queries</h2>
<ul>
{% for query in queries %}
<li><a href="{{ database_url(database) }}/{{ query.name|urlencode }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a></li>
<li><a href="{{ database_url(database) }}/{{ query.name|urlencode }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a> {% if query.requires_auth %} - requires authentication{% endif %}</li>
{% endfor %}
</ul>
{% endif %}

View file

@ -854,3 +854,23 @@ def call_with_supported_arguments(fn, **kwargs):
)
call_with.append(kwargs[parameter])
return fn(*call_with)
def actor_matches_allow(actor, allow):
actor = actor or {}
if allow is None:
return True
for key, values in allow.items():
if values == "*" and key in actor:
return True
if isinstance(values, str):
values = [values]
actor_values = actor.get(key)
if actor_values is None:
return False
if isinstance(actor_values, str):
actor_values = [actor_values]
actor_values = set(actor_values)
if actor_values.intersection(values):
return True
return False

View file

@ -2,13 +2,14 @@ import os
import jinja2
from datasette.utils import (
actor_matches_allow,
to_css_class,
validate_sql_select,
is_url,
path_with_added_args,
path_with_removed_args,
)
from datasette.utils.asgi import AsgiFileDownload
from datasette.utils.asgi import AsgiFileDownload, Response
from datasette.plugins import pm
from .base import DatasetteError, DataView
@ -53,6 +54,16 @@ class DatabaseView(DataView):
)
tables.sort(key=lambda t: (t["hidden"], t["name"]))
canned_queries = [
dict(
query,
requires_auth=not actor_matches_allow(None, query.get("allow", None)),
)
for query in self.ds.get_canned_queries(database)
if actor_matches_allow(
request.scope.get("actor", None), query.get("allow", None)
)
]
return (
{
"database": database,
@ -60,7 +71,7 @@ class DatabaseView(DataView):
"tables": tables,
"hidden_count": len([t for t in tables if t["hidden"]]),
"views": views,
"queries": self.ds.get_canned_queries(database),
"queries": canned_queries,
},
{
"show_hidden": request.args.get("_show_hidden"),
@ -114,6 +125,14 @@ class QueryView(DataView):
params.pop("sql")
if "_shape" in params:
params.pop("_shape")
# Respect canned query permissions
if canned_query:
if not actor_matches_allow(
request.scope.get("actor", None), metadata.get("allow")
):
return Response("Permission denied", status=403)
# Extract any :named parameters
named_parameters = named_parameters or self.re_named_parameter.findall(sql)
named_parameter_values = {

View file

@ -4,14 +4,142 @@
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.
Datasette does not require authentication by default. Any visitor to a Datasette instance can explore the full data and execute SQL queries.
Datasette's plugin system can be used to add many different styles of authentication, such as user accounts, single sign-on or API keys.
.. _authentication_actor:
Actors
======
Through plugins, Datasette can support both authenticated users (with cookies) and authenticated API agents (via authentication tokens). The word "actor" is used to cover both of these cases.
Every request to Datasette has an associated actor value. This can be ``None`` for unauthenticated requests, or a JSON compatible Python dictionary for authenticated users or API agents.
The only required field in an actor is ``"id"``, which must be a string. Plugins may decide to add any other fields to the actor dictionary.
Plugins can use the :ref:`plugin_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request.
.. _authentication_root:
Using the "root" actor
======================
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. This provides access to a small number of debugging features.
To sign in as root, start Datasette using the ``--root`` command-line option, like this::
$ datasette --root
http://127.0.0.1:8001/-/auth-token?token=786fc524e0199d70dc9a581d851f466244e114ca92f33aa3b42a139e9388daa7
INFO: Started server process [25801]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)
The URL on the first line includes a one-use token which can be used to sign in as the "root" actor in your browser. Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm that you are authenticated as an actor that looks like this:
.. code-block:: json
{
"id": "root"
}
.. _authentication_permissions_canned_queries:
Permissions for canned queries
==============================
Datasette's :ref:`canned_queries` default to allowing any user to execute them.
You can limit who is allowed to execute a specific query with the ``"allow"`` key in the :ref:`metadata` configuration for that query.
Here's how to restrict access to a write query to just the "root" user:
.. code-block:: json
{
"databases": {
"mydatabase": {
"queries": {
"add_name": {
"sql": "INSERT INTO names (name) VALUES (:name)",
"write": true,
"allow": {
"id": ["root"]
}
}
}
}
}
}
To allow any of the actors with an ``id`` matching a specific list of values, use this:
.. code-block:: json
{
"allow": {
"id": ["simon", "cleopaws"]
}
}
This works for other keys as well. Imagine an actor that looks like this:
.. code-block:: json
{
"id": "simon",
"roles": ["staff", "developer"]
}
You can provide access to any user that has "developer" as one of their roles like so:
.. code-block:: json
{
"allow": {
"roles": ["developer"]
}
}
Note that "roles" is not a concept that is baked into Datasette - it's more of a convention that plugins can choose to implement and act on.
If you want to provide access to any actor with a value for a specific key, use ``"*"``. For example, to spceify that a query can be accessed by any logged-in user use this:
.. code-block:: json
{
"allow": {
"id": "*"
}
}
These keys act as an "or" mechanism. A actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block.
.. _authentication_actor_matches_allow:
actor_matches_allow()
=====================
Plugins that wish to implement the same permissions scheme as canned queries can take advantage of the ``datasette.utils.actor_matches_allow(actor, allow)`` function:
.. code-block:: python
from datasette.utils import actor_matches_allow
actor_matches_allow({"id": "root"}, {"id": "*"})
# returns True
.. _PermissionsDebugView:
Permissions Debug
=================
The debug tool at ``/-/permissions`` is only available to the root user.
The debug tool at ``/-/permissions`` is only available to the :ref:`authenticated root user <authentication_root>` (or any actor granted the ``permissions-debug`` action according to a plugin).
It shows the thirty most recent permission checks that have been carried out by the Datasette instance.

View file

@ -131,9 +131,7 @@ class TestClient:
if csrftoken_from is not None:
if csrftoken_from is True:
csrftoken_from = path
token_response = await self._request(csrftoken_from)
# Check this had a Vary: Cookie header
assert "Cookie" == token_response.headers["vary"]
token_response = await self._request(csrftoken_from, cookies=cookies)
csrftoken = token_response.cookies["ds_csrftoken"]
cookies["ds_csrftoken"] = csrftoken
post_data["csrftoken"] = csrftoken

View file

@ -24,6 +24,7 @@ def canned_write_client():
"sql": "delete from names where rowid = :rowid",
"write": True,
"on_success_message": "Name deleted",
"allow": {"id": "root"},
},
"update_name": {
"sql": "update names set name = :name where rowid = :rowid",
@ -52,7 +53,11 @@ def test_insert(canned_write_client):
def test_custom_success_message(canned_write_client):
response = canned_write_client.post(
"/data/delete_name", {"rowid": 1}, allow_redirects=False, csrftoken_from=True
"/data/delete_name",
{"rowid": 1},
cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")},
allow_redirects=False,
csrftoken_from=True,
)
assert 302 == response.status
messages = canned_write_client.ds.unsign(
@ -93,3 +98,41 @@ def test_insert_error(canned_write_client):
def test_custom_params(canned_write_client):
response = canned_write_client.get("/data/update_name?extra=foo")
assert '<input type="text" id="qp3" name="extra" value="foo">' in response.text
def test_vary_header(canned_write_client):
# These forms embed a csrftoken so they should be served with Vary: Cookie
assert "vary" not in canned_write_client.get("/data").headers
assert "Cookie" == canned_write_client.get("/data/update_name").headers["vary"]
def test_canned_query_permissions_on_database_page(canned_write_client):
# Without auth only shows three queries
query_names = [
q["name"] for q in canned_write_client.get("/data.json").json["queries"]
]
assert ["add_name", "add_name_specify_id", "update_name"] == query_names
# With auth shows four
response = canned_write_client.get(
"/data.json",
cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")},
)
assert 200 == response.status
assert [
{"name": "add_name", "requires_auth": False},
{"name": "add_name_specify_id", "requires_auth": False},
{"name": "delete_name", "requires_auth": True},
{"name": "update_name", "requires_auth": False},
] == [
{"name": q["name"], "requires_auth": q["requires_auth"]}
for q in response.json["queries"]
]
def test_canned_query_permissions(canned_write_client):
assert 403 == canned_write_client.get("/data/delete_name").status
assert 200 == canned_write_client.get("/data/update_name").status
cookies = {"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")}
assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status
assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status

View file

@ -459,3 +459,33 @@ def test_multi_params(data, should_raise):
p1 = utils.MultiParams(data)
assert "bar" == p1["foo"]
assert ["bar", "baz"] == list(p1.getlist("foo"))
@pytest.mark.parametrize(
"actor,allow,expected",
[
({"id": "root"}, None, True),
({"id": "root"}, {}, False),
(None, None, True),
(None, {}, False),
(None, {"id": "root"}, False),
# Special "*" value for any key:
({"id": "root"}, {"id": "*"}, True),
({}, {"id": "*"}, False),
({"name": "root"}, {"id": "*"}, False),
# Supports single strings or list of values:
({"id": "root"}, {"id": "bob"}, False),
({"id": "root"}, {"id": ["bob"]}, False),
({"id": "root"}, {"id": "root"}, True),
({"id": "root"}, {"id": ["root"]}, True),
# Any matching role will work:
({"id": "garry", "roles": ["staff", "dev"]}, {"roles": ["staff"]}, True),
({"id": "garry", "roles": ["staff", "dev"]}, {"roles": ["dev"]}, True),
({"id": "garry", "roles": ["staff", "dev"]}, {"roles": ["otter"]}, False),
({"id": "garry", "roles": ["staff", "dev"]}, {"roles": ["dev", "otter"]}, True),
({"id": "garry", "roles": []}, {"roles": ["staff"]}, False),
({"id": "garry"}, {"roles": ["staff"]}, False),
],
)
def test_actor_matches_allow(actor, allow, expected):
assert expected == utils.actor_matches_allow(actor, allow)