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>
|
<h2 id="queries">Queries</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for query in queries %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -854,3 +854,23 @@ def call_with_supported_arguments(fn, **kwargs):
|
||||||
)
|
)
|
||||||
call_with.append(kwargs[parameter])
|
call_with.append(kwargs[parameter])
|
||||||
return fn(*call_with)
|
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
|
import jinja2
|
||||||
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
actor_matches_allow,
|
||||||
to_css_class,
|
to_css_class,
|
||||||
validate_sql_select,
|
validate_sql_select,
|
||||||
is_url,
|
is_url,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import AsgiFileDownload
|
from datasette.utils.asgi import AsgiFileDownload, Response
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
||||||
from .base import DatasetteError, DataView
|
from .base import DatasetteError, DataView
|
||||||
|
|
@ -53,6 +54,16 @@ class DatabaseView(DataView):
|
||||||
)
|
)
|
||||||
|
|
||||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
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 (
|
return (
|
||||||
{
|
{
|
||||||
"database": database,
|
"database": database,
|
||||||
|
|
@ -60,7 +71,7 @@ class DatabaseView(DataView):
|
||||||
"tables": tables,
|
"tables": tables,
|
||||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||||
"views": views,
|
"views": views,
|
||||||
"queries": self.ds.get_canned_queries(database),
|
"queries": canned_queries,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"show_hidden": request.args.get("_show_hidden"),
|
"show_hidden": request.args.get("_show_hidden"),
|
||||||
|
|
@ -114,6 +125,14 @@ class QueryView(DataView):
|
||||||
params.pop("sql")
|
params.pop("sql")
|
||||||
if "_shape" in params:
|
if "_shape" in params:
|
||||||
params.pop("_shape")
|
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
|
# Extract any :named parameters
|
||||||
named_parameters = named_parameters or self.re_named_parameter.findall(sql)
|
named_parameters = named_parameters or self.re_named_parameter.findall(sql)
|
||||||
named_parameter_values = {
|
named_parameter_values = {
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,142 @@
|
||||||
Authentication and permissions
|
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:
|
.. _PermissionsDebugView:
|
||||||
|
|
||||||
Permissions Debug
|
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.
|
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 not None:
|
||||||
if csrftoken_from is True:
|
if csrftoken_from is True:
|
||||||
csrftoken_from = path
|
csrftoken_from = path
|
||||||
token_response = await self._request(csrftoken_from)
|
token_response = await self._request(csrftoken_from, cookies=cookies)
|
||||||
# Check this had a Vary: Cookie header
|
|
||||||
assert "Cookie" == token_response.headers["vary"]
|
|
||||||
csrftoken = token_response.cookies["ds_csrftoken"]
|
csrftoken = token_response.cookies["ds_csrftoken"]
|
||||||
cookies["ds_csrftoken"] = csrftoken
|
cookies["ds_csrftoken"] = csrftoken
|
||||||
post_data["csrftoken"] = csrftoken
|
post_data["csrftoken"] = csrftoken
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ def canned_write_client():
|
||||||
"sql": "delete from names where rowid = :rowid",
|
"sql": "delete from names where rowid = :rowid",
|
||||||
"write": True,
|
"write": True,
|
||||||
"on_success_message": "Name deleted",
|
"on_success_message": "Name deleted",
|
||||||
|
"allow": {"id": "root"},
|
||||||
},
|
},
|
||||||
"update_name": {
|
"update_name": {
|
||||||
"sql": "update names set name = :name where rowid = :rowid",
|
"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):
|
def test_custom_success_message(canned_write_client):
|
||||||
response = canned_write_client.post(
|
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
|
assert 302 == response.status
|
||||||
messages = canned_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
|
|
@ -93,3 +98,41 @@ def test_insert_error(canned_write_client):
|
||||||
def test_custom_params(canned_write_client):
|
def test_custom_params(canned_write_client):
|
||||||
response = canned_write_client.get("/data/update_name?extra=foo")
|
response = canned_write_client.get("/data/update_name?extra=foo")
|
||||||
assert '<input type="text" id="qp3" name="extra" value="foo">' in response.text
|
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)
|
p1 = utils.MultiParams(data)
|
||||||
assert "bar" == p1["foo"]
|
assert "bar" == p1["foo"]
|
||||||
assert ["bar", "baz"] == list(p1.getlist("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