mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
e951f7e81f
commit
2b879e462f
14 changed files with 2185 additions and 2 deletions
134
datasette/app.py
134
datasette/app.py
|
|
@ -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$",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue