kopia lustrzana https://github.com/simonw/datasette
UI for restricting permissions on /-/create-token, refs #1947
Also fixes test failures I introduced in #1951pull/1961/head
rodzic
fdf7c27b54
commit
d98a8effb1
|
@ -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
|
||||
|
||||
|
|
|
@ -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"]');
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
Ładowanie…
Reference in New Issue