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>
This commit is contained in:
Simon Willison 2025-10-20 15:59:37 -07:00
commit 2b879e462f
14 changed files with 2185 additions and 2 deletions

View file

@ -52,6 +52,7 @@ from .views.special import (
AllowedResourcesView,
PermissionRulesView,
PermissionCheckView,
TablesView,
)
from .views.table import (
TableInsertView,
@ -308,6 +309,7 @@ class Datasette:
self.immutables = set(immutables or [])
self.databases = collections.OrderedDict()
self.permissions = {} # .invoke_startup() will populate this
self.actions = {} # .invoke_startup() will populate this
try:
self._refresh_schemas_lock = asyncio.Lock()
except RuntimeError as rex:
@ -589,6 +591,33 @@ class Datasette:
if p.abbr:
abbrs[p.abbr] = p
self.permissions[p.name] = p
# Register actions, but watch out for duplicate name/abbr
action_names = {}
action_abbrs = {}
for hook in pm.hook.register_actions(datasette=self):
if hook:
for action in hook:
if (
action.name in action_names
and action != action_names[action.name]
):
raise StartupError(
"Duplicate action name: {}".format(action.name)
)
if (
action.abbr
and action.abbr in action_abbrs
and action != action_abbrs[action.abbr]
):
raise StartupError(
"Duplicate action abbr: {}".format(action.abbr)
)
action_names[action.name] = action
if action.abbr:
action_abbrs[action.abbr] = action
self.actions[action.name] = action
for hook in pm.hook.prepare_jinja2_environment(
env=self._jinja_env, datasette=self
):
@ -1242,6 +1271,107 @@ class Datasette:
# It's visible to everyone
return True, False
async def allowed_resources(
self,
action: str,
actor: dict | None = None,
) -> list["Resource"]:
"""
Return all resources the actor can access for the given action.
Uses SQL to filter resources based on cascading permission rules.
Returns instances of the appropriate Resource subclass.
Example:
tables = await datasette.allowed_resources("view-table", actor)
for table in tables:
print(f"{table.parent}/{table.child}")
"""
from datasette.utils.actions_sql import build_allowed_resources_sql
from datasette.permissions import Resource
action_obj = self.actions.get(action)
if not action_obj:
raise ValueError(f"Unknown action: {action}")
query, params = await build_allowed_resources_sql(self, actor, action)
result = await self.get_internal_database().execute(query, params)
# Instantiate the appropriate Resource subclass for each row
resource_class = action_obj.resource_class
resources = []
for row in result.rows:
# row[0]=parent, row[1]=child, row[2]=reason (ignored)
# Create instance directly with parent/child from base class
resource = object.__new__(resource_class)
Resource.__init__(resource, parent=row[0], child=row[1])
resources.append(resource)
return resources
async def allowed_resources_with_reasons(
self,
action: str,
actor: dict | None = None,
) -> list["AllowedResource"]:
"""
Return allowed resources with permission reasons for debugging.
Uses SQL to filter resources and includes the reason each was allowed.
Returns list of AllowedResource named tuples with (resource, reason).
Example:
debug_info = await datasette.allowed_resources_with_reasons("view-table", actor)
for allowed in debug_info:
print(f"{allowed.resource}: {allowed.reason}")
"""
from datasette.utils.actions_sql import build_allowed_resources_sql
from datasette.permissions import AllowedResource, Resource
action_obj = self.actions.get(action)
if not action_obj:
raise ValueError(f"Unknown action: {action}")
query, params = await build_allowed_resources_sql(self, actor, action)
result = await self.get_internal_database().execute(query, params)
resource_class = action_obj.resource_class
resources = []
for row in result.rows:
# Create instance directly with parent/child from base class
resource = object.__new__(resource_class)
Resource.__init__(resource, parent=row[0], child=row[1])
reason = row[2]
resources.append(AllowedResource(resource=resource, reason=reason))
return resources
async def allowed(
self,
action: str,
resource: "Resource",
actor: dict | None = None,
) -> bool:
"""
Check if actor can perform action on specific resource.
Uses SQL to check permission for a single resource without fetching all resources.
This is efficient - it does NOT call allowed_resources() and check membership.
Example:
from datasette.default_actions import TableResource
can_view = await datasette.allowed(
"view-table",
TableResource(database="analytics", table="users"),
actor
)
"""
from datasette.utils.actions_sql import check_permission_for_resource
return await check_permission_for_resource(
self, actor, action, resource.parent, resource.child
)
async def execute(
self,
db_name,
@ -1726,6 +1856,10 @@ class Datasette:
ApiExplorerView.as_view(self),
r"/-/api$",
)
add_route(
TablesView.as_view(self),
r"/-/tables$",
)
add_route(
LogoutView.as_view(self),
r"/-/logout$",