mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
UI for restricting permissions on /-/create-token, refs #1947
Also fixes test failures I introduced in #1951
This commit is contained in:
parent
fdf7c27b54
commit
d98a8effb1
4 changed files with 150 additions and 47 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]');
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue