UI for restricting permissions on /-/create-token, refs #1947

Also fixes test failures I introduced in #1951
pull/1961/head
Simon Willison 2022-12-13 20:59:28 -08:00
rodzic fdf7c27b54
commit d98a8effb1
4 zmienionych plików z 150 dodań i 47 usunięć

Wyświetl plik

@ -475,7 +475,7 @@ class Datasette:
restrict_database: Optional[Dict[str, Iterable[str]]] = None,
restrict_resource: Optional[Dict[str, Dict[str, Iterable[str]]]] = None,
):
token = {"a": actor_id, "token": "dstok", "t": int(time.time())}
token = {"a": actor_id, "t": int(time.time())}
if expires_after:
token["d"] = expires_after

Wyświetl plik

@ -2,11 +2,36 @@
{% block title %}Create an API token{% endblock %}
{% block extra_head %}
<style type="text/css">
#restrict-permissions label {
display: inline;
width: 90%;
}
</style>
{% endblock %}
{% block content %}
<h1>Create an API token</h1>
<p>This token will allow API access with the same abilities as your current user.</p>
<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>
{% if token %}
<div>
<h2>Your API token</h2>
<form>
<input type="text" class="copyable" style="width: 40%" value="{{ token }}">
<span class="copy-link-wrapper"></span>
</form>
<!--- show token in a <details> -->
<details style="margin-top: 1em">
<summary>Token details</summary>
<pre>{{ token_bits|tojson(4) }}</pre>
</details>
</div>
<h2>Create another token</h2>
{% endif %}
{% if errors %}
{% for error in errors %}
@ -27,23 +52,39 @@
<input type="text" name="expire_duration" style="width: 10%">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Create token">
</div>
</form>
{% if token %}
<div>
<h2>Your API token</h2>
<form>
<input type="text" class="copyable" style="width: 40%" value="{{ token }}">
<span class="copy-link-wrapper"></span>
</form>
<!--- show token in a <details> -->
<details style="margin-top: 1em">
<summary>Token details</summary>
<pre>{{ token_bits|tojson }}</pre>
</details>
</div>
{% endif %}
<details style="margin-top: 1em" id="restrict-permissions">
<summary style="cursor: pointer;">Restrict actions that can be performed using this token</summary>
<h2>All databases and tables</h2>
<ul>
{% for permission in all_permissions %}
<li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
{% endfor %}
</ul>
{% for database in database_with_tables %}
<h2>All tables in "{{ database.name }}"</h2>
<ul>
{% for permission in database_permissions %}
<li><label><input type="checkbox" name="db:{{ database.encoded }}:{{ permission }}"> {{ permission }}</label></li>
{% endfor %}
</ul>
{% endfor %}
<h2>Specific tables</h2>
{% for database in database_with_tables %}
{% for table in database.tables %}
<h3>{{ database.name }}: {{ table.name }}</h3>
<ul>
{% for permission in resource_permissions %}
<li><label><input type="checkbox" name="resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}"> {{ permission }}</label></li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
</details>
</form>
</div>
<script>
var expireDuration = document.querySelector('input[name="expire_duration"]');

Wyświetl plik

@ -1,9 +1,13 @@
import json
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import actor_matches_allow, add_cors_headers
from datasette.utils import (
actor_matches_allow,
add_cors_headers,
tilde_encode,
tilde_decode,
)
from .base import BaseView
import secrets
import time
import urllib
@ -226,19 +230,63 @@ class CreateTokenView(BaseView):
"Token authentication cannot be used to create additional tokens"
)
async def shared(self, request):
self.check_permission(request)
# Build list of databases and tables the user has permission to view
database_with_tables = []
for database in self.ds.databases.values():
if database.name == "_internal":
continue
if not await self.ds.permission_allowed(
request.actor, "view-database", database.name
):
continue
hidden_tables = await database.hidden_table_names()
tables = []
for table in await database.table_names():
if table in hidden_tables:
continue
if not await self.ds.permission_allowed(
request.actor,
"view-table",
resource=(database.name, table),
):
continue
tables.append({"name": table, "encoded": tilde_encode(table)})
database_with_tables.append(
{
"name": database.name,
"encoded": tilde_encode(database.name),
"tables": tables,
}
)
return {
"actor": request.actor,
"all_permissions": self.ds.permissions.keys(),
"database_permissions": [
key
for key, value in self.ds.permissions.items()
if value.takes_database
],
"resource_permissions": [
key
for key, value in self.ds.permissions.items()
if value.takes_resource
],
"database_with_tables": database_with_tables,
}
async def get(self, request):
self.check_permission(request)
return await self.render(
["create_token.html"],
request,
{"actor": request.actor},
["create_token.html"], request, await self.shared(request)
)
async def post(self, request):
self.check_permission(request)
post = await request.post_vars()
errors = []
duration = None
expires_after = None
if post.get("expire_type"):
duration_string = post.get("expire_duration")
if (
@ -250,33 +298,47 @@ class CreateTokenView(BaseView):
else:
unit = post["expire_type"]
if unit == "minutes":
duration = int(duration_string) * 60
expires_after = int(duration_string) * 60
elif unit == "hours":
duration = int(duration_string) * 60 * 60
expires_after = int(duration_string) * 60 * 60
elif unit == "days":
duration = int(duration_string) * 60 * 60 * 24
expires_after = int(duration_string) * 60 * 60 * 24
else:
errors.append("Invalid expire duration unit")
token_bits = None
token = None
if not errors:
token_bits = {
"a": request.actor["id"],
"t": int(time.time()),
}
if duration:
token_bits["d"] = duration
token = "dstok_{}".format(self.ds.sign(token_bits, "token"))
return await self.render(
["create_token.html"],
request,
{
"actor": request.actor,
"errors": errors,
"token": token,
"token_bits": token_bits,
},
# Are there any restrictions?
restrict_all = []
restrict_database = {}
restrict_resource = {}
for key in post:
if key.startswith("all:") and key.count(":") == 1:
restrict_all.append(key.split(":")[1])
elif key.startswith("database:") and key.count(":") == 2:
bits = key.split(":")
database = tilde_decode(bits[1])
action = bits[2]
restrict_database.setdefault(database, []).append(action)
elif key.startswith("resource:") and key.count(":") == 3:
bits = key.split(":")
database = tilde_decode(bits[1])
resource = tilde_decode(bits[2])
action = bits[3]
restrict_resource.setdefault(database, {}).setdefault(
resource, []
).append(action)
token = self.ds.create_token(
request.actor["id"],
expires_after=expires_after,
restrict_all=restrict_all,
restrict_database=restrict_database,
restrict_resource=restrict_resource,
)
token_bits = self.ds.unsign(token[len("dstok_") :], namespace="token")
context = await self.shared(request)
context.update({"errors": errors, "token": token, "token_bits": token_bits})
return await self.render(["create_token.html"], request, context)
class ApiExplorerView(BaseView):

Wyświetl plik

@ -270,7 +270,7 @@ def test_cli_create_token(app_client, expires):
token = result.output.strip()
assert token.startswith("dstok_")
details = app_client.ds.unsign(token[len("dstok_") :], "token")
expected_keys = {"a", "token", "t"}
expected_keys = {"a", "t"}
if expires:
expected_keys.add("d")
assert details.keys() == expected_keys