mirror of
https://github.com/simonw/datasette.git
synced 2026-06-09 10:36:58 +02:00
Closes #2649 * Add register_token_handler plugin hook for pluggable token backends Adds a new register_token_handler hook that allows plugins to provide custom token creation and verification backends. This enables plugins like datasette-oauth to issue tokens without depending on specific backend plugins like datasette-auth-tokens. Key changes: - New datasette/tokens.py with TokenHandler base class and SignedTokenHandler (the default signed-token implementation moved here) - New register_token_handler hookspec in hookspecs.py - Datasette.create_token() is now async and delegates to token handlers - New Datasette.verify_token() method tries all handlers in sequence - handler= parameter on create_token() to select a specific backend - TokenHandler exported from datasette package for plugin use - Fixed actor_from_request loop to await all coroutines (avoids warnings) * Add documentation and hook test for register_token_handler Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst (checked by test_plugin_hooks_are_documented) and a test_hook_* function in test_plugins.py (checked by test_plugin_hooks_have_tests). * Register tokens module as separate default plugin Instead of re-exporting hookimpls from default_permissions/__init__.py, register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS entry. Cleaner and avoids confusing import-for-side-effect patterns. * Replace restrict_x params with TokenRestrictions dataclass Consolidates the three separate restrict_all, restrict_database, and restrict_resource parameters into a single TokenRestrictions dataclass. Cleaner API surface for both Datasette.create_token() and TokenHandler.create_token(). Also clarifies docs re: default handler selection via pluggy ordering. * Add builder methods to TokenRestrictions Adds allow_all(), allow_database(), and allow_resource() methods that return self for chaining. Callers no longer need to manipulate nested dicts directly: restrictions = (TokenRestrictions() .allow_all("view-instance") .allow_database("mydb", "create-table") .allow_resource("mydb", "mytable", "insert-row")) * docs: add 1.0a25 upgrade guide section for create_token() signature change Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393 * docs: note that create_token() is now async in upgrade guide * docs: update internals, plugin_hooks, authentication for new token API - internals.rst: new async create_token() signature with restrictions and handler params, add TokenRestrictions reference docs - plugin_hooks.rst: show full create_token signature in TokenHandler example, note list returns and error cases - authentication.rst: cross-reference TokenRestrictions from the restrictions section * style: apply black formatting to token handler files * docs: fix RST heading underline length in internals.rst * tests: add restrictions round-trip and expiration tests for token handler Covers allow_database/allow_resource builders, _r payload encoding, and token_expires in verified actors. Coverage 76% -> 90%. * tests: add test for signed tokens disabled * fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821 * docs: regenerate plugins.rst with cog * docs: reformat code blocks in plugin_hooks.rst with blacken-docs * docs: add await .verify_token() to internals.rst * tests: rewrite register_token_handler test to use real plugin handler Adds a HardcodedTokenHandler to the test plugins dir that creates tokens like dstok_hardcoded_token_1. The test now exercises creating tokens via the default handler (which is the plugin's hardcoded one), by explicitly naming the hardcoded handler, and by explicitly naming the signed handler -- then verifies each token round-trips correctly. * tests: clarify test_token_handler_via_http tests the default signed handler * fix: use handler="signed" explicitly where signed tokens are expected The HardcodedTokenHandler in my_plugin.py gets globally registered, so create_token() without a handler name picks it up as the default. Fix the create-token view, CLI, and tests to explicitly request the signed handler where they depend on signed token behavior. * fix: use handler="signed" in test_create_table_permissions https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
180 lines
5.7 KiB
Python
180 lines
5.7 KiB
Python
"""
|
|
Token handler system for Datasette.
|
|
|
|
Provides a base class for token handlers and the default signed token handler.
|
|
Plugins can implement register_token_handler to provide custom token backends
|
|
(e.g. database-backed tokens that can be revoked and audited).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import time
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
import itsdangerous
|
|
|
|
if TYPE_CHECKING:
|
|
from datasette.app import Datasette
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class TokenRestrictions:
|
|
"""
|
|
Restrictions to apply to a token, limiting which actions it can perform.
|
|
|
|
Use the builder methods to construct restrictions::
|
|
|
|
restrictions = (TokenRestrictions()
|
|
.allow_all("view-instance")
|
|
.allow_database("mydb", "create-table")
|
|
.allow_resource("mydb", "mytable", "insert-row"))
|
|
"""
|
|
|
|
all: list[str] = dataclasses.field(default_factory=list)
|
|
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
|
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
|
|
|
def allow_all(self, action: str) -> "TokenRestrictions":
|
|
"""Allow an action across all databases and resources."""
|
|
self.all.append(action)
|
|
return self
|
|
|
|
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
|
|
"""Allow an action on a specific database."""
|
|
self.database.setdefault(database, []).append(action)
|
|
return self
|
|
|
|
def allow_resource(
|
|
self, database: str, resource: str, action: str
|
|
) -> "TokenRestrictions":
|
|
"""Allow an action on a specific resource within a database."""
|
|
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
|
return self
|
|
|
|
|
|
class TokenHandler:
|
|
"""
|
|
Base class for token handlers.
|
|
|
|
Subclass this and implement create_token() and verify_token() to provide
|
|
a custom token backend. Return an instance from the register_token_handler hook.
|
|
"""
|
|
|
|
name: str = ""
|
|
|
|
async def create_token(
|
|
self,
|
|
datasette: "Datasette",
|
|
actor_id: str,
|
|
*,
|
|
expires_after: Optional[int] = None,
|
|
restrictions: Optional[TokenRestrictions] = None,
|
|
) -> str:
|
|
"""Create and return a token string for the given actor."""
|
|
raise NotImplementedError
|
|
|
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
|
"""
|
|
Verify a token and return an actor dict, or None if this handler
|
|
does not recognize the token.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class SignedTokenHandler(TokenHandler):
|
|
"""
|
|
Default token handler using itsdangerous signed tokens (dstok_ prefix).
|
|
"""
|
|
|
|
name = "signed"
|
|
|
|
async def create_token(
|
|
self,
|
|
datasette: "Datasette",
|
|
actor_id: str,
|
|
*,
|
|
expires_after: Optional[int] = None,
|
|
restrictions: Optional[TokenRestrictions] = None,
|
|
) -> str:
|
|
if not datasette.setting("allow_signed_tokens"):
|
|
raise ValueError(
|
|
"Signed tokens are not enabled for this Datasette instance"
|
|
)
|
|
|
|
token = {"a": actor_id, "t": int(time.time())}
|
|
|
|
def abbreviate_action(action):
|
|
action_obj = datasette.actions.get(action)
|
|
if not action_obj:
|
|
return action
|
|
return action_obj.abbr or action
|
|
|
|
if expires_after:
|
|
token["d"] = expires_after
|
|
if restrictions and (
|
|
restrictions.all or restrictions.database or restrictions.resource
|
|
):
|
|
token["_r"] = {}
|
|
if restrictions.all:
|
|
token["_r"]["a"] = [abbreviate_action(a) for a in restrictions.all]
|
|
if restrictions.database:
|
|
token["_r"]["d"] = {}
|
|
for database, actions in restrictions.database.items():
|
|
token["_r"]["d"][database] = [abbreviate_action(a) for a in actions]
|
|
if restrictions.resource:
|
|
token["_r"]["r"] = {}
|
|
for database, resources in restrictions.resource.items():
|
|
for resource, actions in resources.items():
|
|
token["_r"]["r"].setdefault(database, {})[resource] = [
|
|
abbreviate_action(a) for a in actions
|
|
]
|
|
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
|
|
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
|
prefix = "dstok_"
|
|
|
|
if not datasette.setting("allow_signed_tokens"):
|
|
return None
|
|
|
|
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
|
|
|
if not token.startswith(prefix):
|
|
return None
|
|
|
|
raw = token[len(prefix) :]
|
|
try:
|
|
decoded = datasette.unsign(raw, namespace="token")
|
|
except itsdangerous.BadSignature:
|
|
return None
|
|
|
|
if "t" not in decoded:
|
|
return None
|
|
created = decoded["t"]
|
|
if not isinstance(created, int):
|
|
return None
|
|
|
|
duration = decoded.get("d")
|
|
if duration is not None and not isinstance(duration, int):
|
|
return None
|
|
|
|
if (duration is None and max_signed_tokens_ttl) or (
|
|
duration is not None
|
|
and max_signed_tokens_ttl
|
|
and duration > max_signed_tokens_ttl
|
|
):
|
|
duration = max_signed_tokens_ttl
|
|
|
|
if duration:
|
|
if time.time() - created > duration:
|
|
return None
|
|
|
|
actor = {"id": decoded["a"], "token": "dstok"}
|
|
|
|
if "_r" in decoded:
|
|
actor["_r"] = decoded["_r"]
|
|
|
|
if duration:
|
|
actor["token_expires"] = created + duration
|
|
|
|
return actor
|