datasette.create_token() method, closes #1951

This commit is contained in:
Simon Willison 2022-12-13 18:42:01 -08:00
commit fdf7c27b54
3 changed files with 113 additions and 31 deletions

View file

@ -1,5 +1,5 @@
import asyncio import asyncio
from typing import Sequence, Union, Tuple, Optional from typing import Sequence, Union, Tuple, Optional, Dict, Iterable
import asgi_csrf import asgi_csrf
import collections import collections
import datetime import datetime
@ -16,6 +16,7 @@ import re
import secrets import secrets
import sys import sys
import threading import threading
import time
import urllib.parse import urllib.parse
from concurrent import futures from concurrent import futures
from pathlib import Path from pathlib import Path
@ -465,6 +466,45 @@ class Datasette:
def unsign(self, signed, namespace="default"): def unsign(self, signed, namespace="default"):
return URLSafeSerializer(self._secret, namespace).loads(signed) return URLSafeSerializer(self._secret, namespace).loads(signed)
def create_token(
self,
actor_id: str,
*,
expires_after: Optional[int] = None,
restrict_all: Optional[Iterable[str]] = None,
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())}
if expires_after:
token["d"] = expires_after
def abbreviate_action(action):
# rename to abbr if possible
permission = self.permissions.get(action)
if not permission:
return action
return permission.abbr or action
if expires_after:
token["d"] = expires_after
if restrict_all or restrict_database or restrict_resource:
token["_r"] = {}
if restrict_all:
token["_r"]["a"] = [abbreviate_action(a) for a in restrict_all]
if restrict_database:
token["_r"]["d"] = {}
for database, actions in restrict_database.items():
token["_r"]["d"][database] = [abbreviate_action(a) for a in actions]
if restrict_resource:
token["_r"]["r"] = {}
for database, resources in restrict_resource.items():
for resource, actions in resources.items():
token["_r"]["r"].setdefault(database, {})[resource] = [
abbreviate_action(a) for a in actions
]
return "dstok_{}".format(self.sign(token, namespace="token"))
def get_database(self, name=None, route=None): def get_database(self, name=None, route=None):
if route is not None: if route is not None:
matches = [db for db in self.databases.values() if db.route == route] matches = [db for db in self.databases.values() if db.route == route]

View file

@ -11,7 +11,6 @@ from runpy import run_module
import shutil import shutil
from subprocess import call from subprocess import call
import sys import sys
import time
import webbrowser import webbrowser
from .app import ( from .app import (
OBSOLETE_SETTINGS, OBSOLETE_SETTINGS,
@ -730,41 +729,40 @@ def create_token(
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.run_until_complete(ds.invoke_startup()) loop.run_until_complete(ds.invoke_startup())
def fix_action(action): # Warn about any unknown actions
# Warn if invalid, rename to abbr if possible actions = []
permission = ds.permissions.get(action) actions.extend(alls)
if not permission: actions.extend([p[1] for p in databases])
# Output red message actions.extend([p[2] for p in resources])
for action in actions:
if not ds.permissions.get(action):
click.secho( click.secho(
f" Unknown permission: {action} ", f" Unknown permission: {action} ",
fg="red", fg="red",
err=True, err=True,
) )
return action
return permission.abbr or action
bits = {"a": id, "token": "dstok", "t": int(time.time())} restrict_database = {}
if expires_after: for database, action in databases:
bits["d"] = expires_after restrict_database.setdefault(database, []).append(action)
if alls or databases or resources: restrict_resource = {}
bits["_r"] = {} for database, resource, action in resources:
if alls: restrict_resource.setdefault(database, {}).setdefault(resource, []).append(
bits["_r"]["a"] = [fix_action(a) for a in alls] action
if databases: )
bits["_r"]["d"] = {}
for database, action in databases: token = ds.create_token(
bits["_r"]["d"].setdefault(database, []).append(fix_action(action)) id,
if resources: expires_after=expires_after,
bits["_r"]["r"] = {} restrict_all=alls,
for database, table, action in resources: restrict_database=restrict_database,
bits["_r"]["r"].setdefault(database, {}).setdefault(table, []).append( restrict_resource=restrict_resource,
fix_action(action) )
) click.echo(token)
token = ds.sign(bits, namespace="token")
click.echo("dstok_{}".format(token))
if debug: if debug:
encoded = token[len("dstok_") :]
click.echo("\nDecoded:\n") click.echo("\nDecoded:\n")
click.echo(json.dumps(ds.unsign(token, namespace="token"), indent=2)) click.echo(json.dumps(ds.unsign(encoded, namespace="token"), indent=2))
pm.hook.register_commands(cli=cli) pm.hook.register_commands(cli=cli)

View file

@ -419,6 +419,50 @@ The following example runs three checks in a row, similar to :ref:`datasette_ens
], ],
) )
.create_token(actor_id, expires_after=None, restrict_all=None, restrict_database=None, restrict_resource=None)
--------------------------------------------------------------------------------------------------------------
``actor_id`` - string
The ID of the actor to create a token for.
``expires_after`` - int, optional
The number of seconds after which the token should expire.
``restrict_all`` - iterable, optional
A list of actions that this token should be restricted to across all databases and resources.
``restrict_database`` - dict, optional
For restricting actions within specific databases, e.g. ``{"mydb": ["view-table", "view-query"]}``.
``restrict_resource`` - dict, optional
For restricting actions to specific resources (tables, SQL views and :ref:`canned_queries`) within a database. For example: ``{"mydb": {"mytable": ["insert-row", "update-row"]}}``.
This method returns a signed :ref:`API token <CreateTokenView>` of the format ``dstok_...`` which can be used to authenticate requests to the Datasette API.
All tokens must have an ``actor_id`` string indicating the ID of the actor which the token will act on behalf of.
Tokens default to lasting forever, but can be set to expire after a given number of seconds using the ``expires_after`` argument. The following code creates a token for ``user1`` that will expire after an hour:
.. code-block:: python
token = datasette.create_token(
actor_id="user1",
expires_after=3600,
)
The three ``restrict_*`` arguments can be used to create a token that has additional restrictions beyond what the associated actor is allowed to do.
The following example creates a token that can access ``view-instance`` and ``view-table`` across everything, can additionally use ``view-query`` for anything in the ``docs`` database and is allowed to execute ``insert-row`` and ``update-row`` in the ``attachments`` table in that database:
.. code-block:: python
token = datasette.create_token(
actor_id="user1",
restrict_all=("view-instance", "view-table"),
restrict_database={"docs": ("view-query",)},
restrict_resource={"docs": {"attachments": ("insert-row", "update-row")}},
)
.. _datasette_get_database: .. _datasette_get_database:
.get_database(name) .get_database(name)