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

Also fixes test failures I introduced in #1951
This commit is contained in:
Simon Willison 2022-12-13 20:59:28 -08:00
commit d98a8effb1
4 changed files with 150 additions and 47 deletions

View file

@ -475,7 +475,7 @@ class Datasette:
restrict_database: Optional[Dict[str, Iterable[str]]] = None, restrict_database: Optional[Dict[str, Iterable[str]]] = None,
restrict_resource: Optional[Dict[str, 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: if expires_after:
token["d"] = expires_after token["d"] = expires_after

View file

@ -2,11 +2,36 @@
{% block title %}Create an API token{% endblock %} {% 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 %} {% block content %}
<h1>Create an API token</h1> <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 %} {% if errors %}
{% for error in errors %} {% for error in errors %}
@ -27,23 +52,39 @@
<input type="text" name="expire_duration" style="width: 10%"> <input type="text" name="expire_duration" style="width: 10%">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}"> <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Create token"> <input type="submit" value="Create token">
</div>
</form>
{% if token %} <details style="margin-top: 1em" id="restrict-permissions">
<div> <summary style="cursor: pointer;">Restrict actions that can be performed using this token</summary>
<h2>Your API token</h2> <h2>All databases and tables</h2>
<form> <ul>
<input type="text" class="copyable" style="width: 40%" value="{{ token }}"> {% for permission in all_permissions %}
<span class="copy-link-wrapper"></span> <li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
</form> {% endfor %}
<!--- show token in a <details> --> </ul>
<details style="margin-top: 1em">
<summary>Token details</summary> {% for database in database_with_tables %}
<pre>{{ token_bits|tojson }}</pre> <h2>All tables in "{{ database.name }}"</h2>
</details> <ul>
</div> {% for permission in database_permissions %}
{% endif %} <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> <script>
var expireDuration = document.querySelector('input[name="expire_duration"]'); var expireDuration = document.querySelector('input[name="expire_duration"]');

View file

@ -1,9 +1,13 @@
import json import json
from datasette.utils.asgi import Response, Forbidden 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 from .base import BaseView
import secrets import secrets
import time
import urllib import urllib
@ -226,19 +230,63 @@ class CreateTokenView(BaseView):
"Token authentication cannot be used to create additional tokens" "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): async def get(self, request):
self.check_permission(request) self.check_permission(request)
return await self.render( return await self.render(
["create_token.html"], ["create_token.html"], request, await self.shared(request)
request,
{"actor": request.actor},
) )
async def post(self, request): async def post(self, request):
self.check_permission(request) self.check_permission(request)
post = await request.post_vars() post = await request.post_vars()
errors = [] errors = []
duration = None expires_after = None
if post.get("expire_type"): if post.get("expire_type"):
duration_string = post.get("expire_duration") duration_string = post.get("expire_duration")
if ( if (
@ -250,33 +298,47 @@ class CreateTokenView(BaseView):
else: else:
unit = post["expire_type"] unit = post["expire_type"]
if unit == "minutes": if unit == "minutes":
duration = int(duration_string) * 60 expires_after = int(duration_string) * 60
elif unit == "hours": elif unit == "hours":
duration = int(duration_string) * 60 * 60 expires_after = int(duration_string) * 60 * 60
elif unit == "days": elif unit == "days":
duration = int(duration_string) * 60 * 60 * 24 expires_after = int(duration_string) * 60 * 60 * 24
else: else:
errors.append("Invalid expire duration unit") errors.append("Invalid expire duration unit")
token_bits = None
token = None # Are there any restrictions?
if not errors: restrict_all = []
token_bits = { restrict_database = {}
"a": request.actor["id"], restrict_resource = {}
"t": int(time.time()),
} for key in post:
if duration: if key.startswith("all:") and key.count(":") == 1:
token_bits["d"] = duration restrict_all.append(key.split(":")[1])
token = "dstok_{}".format(self.ds.sign(token_bits, "token")) elif key.startswith("database:") and key.count(":") == 2:
return await self.render( bits = key.split(":")
["create_token.html"], database = tilde_decode(bits[1])
request, action = bits[2]
{ restrict_database.setdefault(database, []).append(action)
"actor": request.actor, elif key.startswith("resource:") and key.count(":") == 3:
"errors": errors, bits = key.split(":")
"token": token, database = tilde_decode(bits[1])
"token_bits": token_bits, 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): class ApiExplorerView(BaseView):

View file

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