datasette/datasette/plugins.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

124 lines
4.2 KiB
Python
Raw Normal View History

import importlib
import importlib.metadata as importlib_metadata
import importlib.resources as importlib_resources
import os
import sys
from pprint import pprint
import pluggy
from . import hookspecs
DEFAULT_PLUGINS = (
"datasette.publish.heroku",
"datasette.publish.cloudrun",
"datasette.facets",
"datasette.filters",
"datasette.sql_functions",
"datasette.actor_auth_cookie",
"datasette.default_permissions",
register_token_handler() plugin hook for custom API token backends (#2650) 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
2026-02-25 16:32:45 -08:00
"datasette.default_permissions.tokens",
Implement resource-based permission system with SQL-driven access control This introduces a new hierarchical permission system that uses SQL queries for efficient permission checking across resources. The system replaces the older permission_allowed() pattern with a more flexible resource-based approach. Core changes: - New Resource ABC and Action dataclass in datasette/permissions.py * Resources represent hierarchical entities (instance, database, table) * Each resource type implements resources_sql() to list all instances * Actions define operations on resources with cascading rules - New plugin hook: register_actions(datasette) * Plugins register actions with their associated resource types * Replaces register_permissions() and register_resource_types() * See docs/plugin_hooks.rst for full documentation - Three new Datasette methods for permission checks: * allowed_resources(action, actor) - returns list[Resource] * allowed_resources_with_reasons(action, actor) - for debugging * allowed(action, resource, actor) - checks single resource * All use SQL for filtering, never Python iteration - New /-/tables endpoint (TablesView) * Returns JSON list of tables user can view * Supports ?q= parameter for regex filtering * Format: {"matches": [{"name": "db/table", "url": "/db/table"}]} * Respects all permission rules from configuration and plugins - SQL-based permission evaluation (datasette/utils/actions_sql.py) * Cascading rules: child-level → parent-level → global-level * DENY beats ALLOW at same specificity * Uses CTEs for efficient SQL-only filtering * Combines permission_resources_sql() hook results - Default actions in datasette/default_actions.py * InstanceResource, DatabaseResource, TableResource, QueryResource * Core actions: view-instance, view-database, view-table, etc. - Fixed default_permissions.py to handle database-level allow blocks * Now creates parent-level rules for view-table action * Fixes: datasette ... -s databases.fixtures.allow.id root Documentation: - Comprehensive register_actions() hook documentation - Detailed resources_sql() method explanation - /-/tables endpoint documentation in docs/introspection.rst - Deprecated register_permissions() with migration guide Tests: - tests/test_actions_sql.py: 7 tests for core permission API - tests/test_tables_endpoint.py: 13 tests for /-/tables endpoint - All 118 documentation tests pass - Tests verify SQL does filtering (not Python) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:59:37 -07:00
"datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters",
"datasette.blob_renderer",
2026-05-23 16:57:09 -07:00
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions",
"datasette.handle_exception",
"datasette.forbidden",
"datasette.events",
)
pm = pluggy.PluginManager("datasette")
pm.add_hookspecs(hookspecs)
DATASETTE_TRACE_PLUGINS = os.environ.get("DATASETTE_TRACE_PLUGINS", None)
def before(hook_name, hook_impls, kwargs):
print(file=sys.stderr)
print(f"{hook_name}:", file=sys.stderr)
pprint(kwargs, width=40, indent=4, stream=sys.stderr)
print("Hook implementations:", file=sys.stderr)
pprint(hook_impls, width=40, indent=4, stream=sys.stderr)
def after(outcome, hook_name, hook_impls, kwargs):
results = outcome.get_result()
if not isinstance(results, list):
results = [results]
print("Results:", file=sys.stderr)
pprint(results, width=40, indent=4, stream=sys.stderr)
if DATASETTE_TRACE_PLUGINS:
pm.add_hookcall_monitoring(before, after)
DATASETTE_LOAD_PLUGINS = os.environ.get("DATASETTE_LOAD_PLUGINS", None)
if not hasattr(sys, "_called_from_test") and DATASETTE_LOAD_PLUGINS is None:
# Only load plugins if not running tests
pm.load_setuptools_entrypoints("datasette")
# Load any plugins specified in DATASETTE_LOAD_PLUGINS")
if DATASETTE_LOAD_PLUGINS is not None:
for package_name in [
name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip()
]:
try:
distribution = importlib_metadata.distribution(package_name)
entry_points = distribution.entry_points
for entry_point in entry_points:
if entry_point.group == "datasette":
mod = entry_point.load()
pm.register(mod, name=entry_point.name)
# Ensure name can be found in plugin_to_distinfo later:
pm._plugin_distinfo.append((mod, distribution))
except importlib_metadata.PackageNotFoundError:
sys.stderr.write(f"Plugin {package_name} could not be found\n")
# Load default plugins
for plugin in DEFAULT_PLUGINS:
mod = importlib.import_module(plugin)
pm.register(mod, plugin)
def get_plugins():
plugins = []
plugin_to_distinfo = dict(pm.list_plugin_distinfo())
for plugin in pm.get_plugins():
static_path = None
templates_path = None
2025-11-13 10:31:03 -08:00
plugin_name = (
plugin.__name__
if hasattr(plugin, "__name__")
else plugin.__class__.__name__
)
if plugin_name not in DEFAULT_PLUGINS:
try:
2025-11-13 10:31:03 -08:00
if (importlib_resources.files(plugin_name) / "static").is_dir():
static_path = str(importlib_resources.files(plugin_name) / "static")
if (importlib_resources.files(plugin_name) / "templates").is_dir():
templates_path = str(
2025-11-13 10:31:03 -08:00
importlib_resources.files(plugin_name) / "templates"
)
except (TypeError, ModuleNotFoundError):
# Caused by --plugins_dir= plugins
pass
plugin_info = {
2025-11-13 10:31:03 -08:00
"name": plugin_name,
"static_path": static_path,
"templates_path": templates_path,
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
}
distinfo = plugin_to_distinfo.get(plugin)
if distinfo:
plugin_info["version"] = distinfo.version
plugin_info["name"] = distinfo.name or distinfo.project_name
plugins.append(plugin_info)
return plugins