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
ec38ad3768
commit
7db754c284
14 changed files with 2185 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue