register_token_handler() plugin hook for custom API token backends#2650
register_token_handler() plugin hook for custom API token backends#2650
Conversation
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) https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
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). https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
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. https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
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. https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
datasette/tokens.py
Outdated
| restrict_all: Optional[Iterable[str]] = None, | ||
| restrict_database: Optional[Dict[str, Iterable[str]]] = None, | ||
| restrict_resource: Optional[Dict[str, Dict[str, Iterable[str]]]] = None, |
There was a problem hiding this comment.
Let's do better than this - a more type-friendly API.
datasette/tokens.py
Outdated
| @dataclasses.dataclass | ||
| class TokenRestrictions: | ||
| """ | ||
| Restrictions to apply to a token, limiting which actions it can perform. | ||
|
|
||
| - all: actions allowed against all databases/resources | ||
| - database: {database_name: [actions]} for database-level restrictions | ||
| - resource: {database_name: {resource_name: [actions]}} for resource-level | ||
| """ | ||
|
|
||
| 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 | ||
| ) |
There was a problem hiding this comment.
@dataclasses.dataclass
class TokenRestrictions:
"""
Restrictions to apply to a token, limiting which actions it can perform.
- all: actions allowed against all databases/resources
- database: {database_name: [actions]} for database-level restrictions
- resource: {database_name: {resource_name: [actions]}} for resource-level
"""
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
)
Don't write code until I say, but propose a design for this that uses more of a builder pattern and hence is less dictionary-heavy in terms of how it is interacted with
There was a problem hiding this comment.
Claude suggested:
(TokenRestrictions()
.allow("view-instance")
.allow("view-database")
.allow("create-table", database="mydb")
.allow("insert-row", database="mydb", resource="mytable"))I countered:
no lets do three methods for each category of restriction
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"))
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2650 +/- ##
==========================================
- Coverage 90.44% 90.42% -0.02%
==========================================
Files 52 53 +1
Lines 8018 8075 +57
==========================================
+ Hits 7252 7302 +50
- Misses 766 773 +7 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
- 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 https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
…dler Covers allow_database/allow_resource builders, _r payload encoding, and token_expires in verified actors. Coverage 76% -> 90%. https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
|
I tested this breaking change in a branch of
|
| @pytest.mark.asyncio | ||
| async def test_hook_register_token_handler(ds_client): | ||
| handlers = ds_client.ds._token_handlers() | ||
| assert len(handlers) >= 1 | ||
| # Default signed handler should be present | ||
| assert any(h.name == "signed" for h in handlers) | ||
| # Should be able to create and verify a token | ||
| token = await ds_client.ds.create_token("test") | ||
| assert token.startswith("dstok_") | ||
| actor = await ds_client.ds.verify_token(token) | ||
| assert actor["id"] == "test" | ||
|
|
There was a problem hiding this comment.
I don't like the test for test_hook_register_token_handler - the other tests in that module act against a real plugin that has been registered, including testing that it is used. I think that test should use a special handler that creates a token with e.g. dstok_hardcoded_token_1 which we can then create (as the default plugin handler and also by specifying its name) and then validate
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. https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
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. https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
* Add build/ and dist/ to .gitignore These directories are generated by build tools (uv, pip, setuptools) and should not be tracked. * Use TokenRestrictions API and await async create_token Adapt to Datasette's new token API: build a TokenRestrictions object using builder methods instead of separate restrict_all/restrict_database/ restrict_resource dicts, and await the now-async create_token() call. * datasette>=1.0a25 Refs #38 (comment) Refs simonw/datasette#2650 (comment) Refs simonw/datasette#2649

Initial prompt was the description and first comment from:
Summary
This PR introduces a pluggable token handler system that allows plugins to provide custom token backends while maintaining backward compatibility with the existing signed token implementation.
Key Changes
TokenHandlerbase class (datasette/tokens.py): Abstract base class that plugins can subclass to implement custom token creation and verification logicSignedTokenHandlerimplementation: Moved existing signed token logic into a dedicated handler class that implements theTokenHandlerinterfaceregister_token_handler: New hookspec indatasette/hookspecs.pyallowing plugins to register custom token handlerscreate_token()to async and added newverify_token()async method toDatasetteclass_token_handlers()method to collect all registered handlers from pluginscreate_token()now accepts optionalhandlerparameter to select a specific handler by name; defaults to first registered handlerverify_token()tries all registered handlers until one recognizes the tokendatasette/default_permissions/tokens.pyto delegate to the new handler system instead of duplicating logicdatasette create-tokencommand to use the new asynccreate_token()methodImplementation Details
SignedTokenHandleris automatically registered via the default permissions pluginhttps://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
📚 Documentation preview 📚: https://datasette--2650.org.readthedocs.build/en/2650/