From fdf7c27b5438f02153c3a7f8ad1b320e4b29e4f4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 13 Dec 2022 18:42:01 -0800 Subject: [PATCH] datasette.create_token() method, closes #1951 --- datasette/app.py | 42 +++++++++++++++++++++++++++++++++- datasette/cli.py | 56 ++++++++++++++++++++++------------------------ docs/internals.rst | 44 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 878e484f..fd28a016 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,5 +1,5 @@ import asyncio -from typing import Sequence, Union, Tuple, Optional +from typing import Sequence, Union, Tuple, Optional, Dict, Iterable import asgi_csrf import collections import datetime @@ -16,6 +16,7 @@ import re import secrets import sys import threading +import time import urllib.parse from concurrent import futures from pathlib import Path @@ -465,6 +466,45 @@ class Datasette: def unsign(self, signed, namespace="default"): 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): if route is not None: matches = [db for db in self.databases.values() if db.route == route] diff --git a/datasette/cli.py b/datasette/cli.py index f9faf026..b3ae643a 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -11,7 +11,6 @@ from runpy import run_module import shutil from subprocess import call import sys -import time import webbrowser from .app import ( OBSOLETE_SETTINGS, @@ -730,41 +729,40 @@ def create_token( loop = asyncio.get_event_loop() loop.run_until_complete(ds.invoke_startup()) - def fix_action(action): - # Warn if invalid, rename to abbr if possible - permission = ds.permissions.get(action) - if not permission: - # Output red message + # Warn about any unknown actions + actions = [] + actions.extend(alls) + actions.extend([p[1] for p in databases]) + actions.extend([p[2] for p in resources]) + for action in actions: + if not ds.permissions.get(action): click.secho( - f" Unknown permission: {action} ", + f" Unknown permission: {action} ", fg="red", err=True, ) - return action - return permission.abbr or action - bits = {"a": id, "token": "dstok", "t": int(time.time())} - if expires_after: - bits["d"] = expires_after - if alls or databases or resources: - bits["_r"] = {} - if alls: - bits["_r"]["a"] = [fix_action(a) for a in alls] - if databases: - bits["_r"]["d"] = {} - for database, action in databases: - bits["_r"]["d"].setdefault(database, []).append(fix_action(action)) - if resources: - bits["_r"]["r"] = {} - for database, table, action in resources: - bits["_r"]["r"].setdefault(database, {}).setdefault(table, []).append( - fix_action(action) - ) - token = ds.sign(bits, namespace="token") - click.echo("dstok_{}".format(token)) + restrict_database = {} + for database, action in databases: + restrict_database.setdefault(database, []).append(action) + restrict_resource = {} + for database, resource, action in resources: + restrict_resource.setdefault(database, {}).setdefault(resource, []).append( + action + ) + + token = ds.create_token( + id, + expires_after=expires_after, + restrict_all=alls, + restrict_database=restrict_database, + restrict_resource=restrict_resource, + ) + click.echo(token) if debug: + encoded = token[len("dstok_") :] 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) diff --git a/docs/internals.rst b/docs/internals.rst index fe495264..7fb97bf7 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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 ` 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: .get_database(name)