mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Merge pull request #803 from simonw/canned-query-permissions
This commit is contained in:
commit
415ccd7cbd
7 changed files with 247 additions and 9 deletions
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue