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 7db754c284
14 changed files with 2185 additions and 2 deletions

View file

@ -144,6 +144,47 @@ Shows currently attached databases. `Databases example <https://latest.datasette
}
]
.. _TablesView:
/-/tables
---------
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
`Tables example <https://latest.datasette.io/-/tables>`_:
.. code-block:: json
{
"matches": [
{
"name": "fixtures/facetable",
"url": "/fixtures/facetable"
},
{
"name": "fixtures/searchable",
"url": "/fixtures/searchable"
}
]
}
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
.. code-block:: json
{
"matches": [
{
"name": "fixtures/facetable",
"url": "/fixtures/facetable"
}
]
}
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
.. _JsonDataView_threads:
/-/threads

View file

@ -782,6 +782,9 @@ The plugin hook can then be used to register the new facet class like this:
register_permissions(datasette)
--------------------------------
.. note::
This hook is deprecated. Use :ref:`plugin_register_actions` instead, which provides a more flexible resource-based permission system.
If your plugin needs to register additional permissions unique to that plugin - ``upload-csvs`` for example - you can return a list of those permissions from this hook.
.. code-block:: python
@ -824,6 +827,141 @@ The fields of the ``Permission`` class are as follows:
This should only be ``True`` if you want anonymous users to be able to take this action.
.. _plugin_register_actions:
register_actions(datasette)
----------------------------
If your plugin needs to register actions that can be checked with Datasette's new resource-based permission system, return a list of those actions from this hook.
Actions define what operations can be performed on resources (like viewing a table, executing SQL, or custom plugin actions).
.. code-block:: python
from datasette import hookimpl
from datasette.permissions import Action, Resource
class DocumentCollectionResource(Resource):
"""A collection of documents."""
name = "document-collection"
parent_name = None
def __init__(self, collection: str):
super().__init__(parent=collection, child=None)
@classmethod
def resources_sql(cls) -> str:
return """
SELECT collection_name AS parent, NULL AS child
FROM document_collections
"""
class DocumentResource(Resource):
"""A document in a collection."""
name = "document"
parent_name = "document-collection"
def __init__(self, collection: str, document: str):
super().__init__(parent=collection, child=document)
@classmethod
def resources_sql(cls) -> str:
return """
SELECT collection_name AS parent, document_id AS child
FROM documents
"""
@hookimpl
def register_actions(datasette):
return [
Action(
name="list-documents",
abbr="ld",
description="List documents in a collection",
takes_parent=True,
takes_child=False,
resource_class=DocumentCollectionResource,
),
Action(
name="view-document",
abbr="vdoc",
description="View document",
takes_parent=True,
takes_child=True,
resource_class=DocumentResource,
),
Action(
name="edit-document",
abbr="edoc",
description="Edit document",
takes_parent=True,
takes_child=True,
resource_class=DocumentResource,
),
]
The fields of the ``Action`` dataclass are as follows:
``name`` - string
The name of the action, e.g. ``view-document``. This should be unique across all plugins.
``abbr`` - string or None
An abbreviation of the action, e.g. ``vdoc``. This is optional. Since this needs to be unique across all installed plugins it's best to choose carefully or use ``None``.
``description`` - string or None
A human-readable description of what the action allows you to do.
``takes_parent`` - boolean
``True`` if this action requires a parent identifier (like a database name).
``takes_child`` - boolean
``True`` if this action requires a child identifier (like a table or document name).
``resource_class`` - type[Resource]
The Resource subclass that defines what kind of resource this action applies to. Your Resource subclass must:
- Define a ``name`` class attribute (e.g., ``"document"``)
- Optionally define a ``parent_name`` class attribute (e.g., ``"collection"``)
- Implement a ``resources_sql()`` classmethod that returns SQL returning all resources as ``(parent, child)`` columns
- Have an ``__init__`` method that accepts appropriate parameters and calls ``super().__init__(parent=..., child=...)``
The ``resources_sql()`` method
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``resources_sql()`` classmethod is crucial to Datasette's permission system. It returns a SQL query that lists all resources of that type that exist in the system.
This SQL query is used by Datasette to efficiently check permissions across multiple resources at once. When a user requests a list of resources (like tables, documents, or other entities), Datasette uses this SQL to:
1. Get all resources of this type from your data catalog
2. Combine it with permission rules from the ``permission_resources_sql`` hook
3. Use SQL joins and filtering to determine which resources the actor can access
4. Return only the permitted resources
The SQL query **must** return exactly two columns:
- ``parent`` - The parent identifier (e.g., database name, collection name), or ``NULL`` for top-level resources
- ``child`` - The child identifier (e.g., table name, document ID), or ``NULL`` for parent-only resources
For example, if you're building a document management plugin with collections and documents stored in a ``documents`` table, your ``resources_sql()`` might look like:
.. code-block:: python
@classmethod
def resources_sql(cls) -> str:
return """
SELECT collection_name AS parent, document_id AS child
FROM documents
"""
This tells Datasette "here's how to find all documents in the system - look in the documents table and get the collection name and document ID for each one."
The permission system then uses this query along with rules from plugins to determine which documents each user can access, all efficiently in SQL rather than loading everything into Python.
.. _plugin_asgi_wrapper:
asgi_wrapper(datasette)