Add keyset pagination to allowed_resources() (#2562)

* Add keyset pagination to allowed_resources()

This replaces the unbounded list return with PaginatedResources,
which supports efficient keyset pagination for handling thousands
of resources.

Closes #2560

Changes:
- allowed_resources() now returns PaginatedResources instead of list
- Added limit (1-1000, default 100) and next (keyset token) parameters
- Added include_reasons parameter (replaces allowed_resources_with_reasons)
- Removed allowed_resources_with_reasons() method entirely
- PaginatedResources.all() async generator for automatic pagination
- Uses tilde-encoding for tokens (matching table pagination)
- Updated all callers to use .resources accessor
- Updated documentation with new API and examples

The PaginatedResources object has:
- resources: List of Resource objects for current page
- next: Token for next page (None if no more results)
- all(): Async generator that yields all resources across pages

Example usage:
    page = await ds.allowed_resources("view-table", actor, limit=100)
    for table in page.resources:
        print(table.child)

    # Iterate all pages automatically
    async for table in page.all():
        print(table.child)

🤖 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-31 14:50:46 -07:00 committed by GitHub
commit 400fa08e4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 366 additions and 223 deletions

View file

@ -387,32 +387,80 @@ The method returns ``True`` if the permission is granted, ``False`` if denied.
.. _datasette_allowed_resources:
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False)
---------------------------------------------------------------------------------------
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)
------------------------------------------------------------------------------------------------------------------------------------
Returns a list of ``Resource`` objects that the actor can access for the
specified action. Each returned object is an instance of the action's
``resource_class`` and may include a ``.private`` attribute (when
``include_is_private=True``) to indicate that anonymous actors would be denied
access.
Returns a ``PaginatedResources`` object containing resources that the actor can access for the specified action, with support for keyset pagination.
Example::
``action`` - string
The action name (e.g., "view-table", "view-database")
tables = await datasette.allowed_resources(
"view-table", actor=request.actor, parent="fixtures"
``actor`` - dictionary, optional
The authenticated actor. Defaults to ``None`` for unauthenticated requests.
``parent`` - string, optional
Optional parent filter (e.g., database name) to limit results
``include_is_private`` - boolean, optional
If True, adds a ``.private`` attribute to each Resource indicating whether anonymous users can access it
``include_reasons`` - boolean, optional
If True, adds a ``.reasons`` attribute with a list of strings describing why access was granted (useful for debugging)
``limit`` - integer, optional
Maximum number of results to return per page (1-1000, default 100)
``next`` - string, optional
Keyset token from a previous page for pagination
The method returns a ``PaginatedResources`` object (from ``datasette.utils``) with the following attributes:
``resources`` - list
List of ``Resource`` objects for the current page
``next`` - string or None
Token for the next page, or ``None`` if no more results exist
Example usage:
.. code-block:: python
# Get first page of tables
page = await datasette.allowed_resources(
"view-table",
actor=request.actor,
parent="fixtures",
limit=50,
)
for table in tables:
for table in page.resources:
print(table.parent, table.child)
if hasattr(table, "private"):
print(f" Private: {table.private}")
# Get next page if available
if page.next:
next_page = await datasette.allowed_resources(
"view-table", actor=request.actor, next=page.next
)
# Iterate through all results automatically
page = await datasette.allowed_resources(
"view-table", actor=request.actor
)
async for table in page.all():
print(table.parent, table.child)
This method uses :ref:`datasette_allowed_resources_sql` under the hood and is an
efficient way to list the databases, tables or queries visible to a user.
# With reasons for debugging
page = await datasette.allowed_resources(
"view-table", actor=request.actor, include_reasons=True
)
for table in page.resources:
print(f"{table.child}: {table.reasons}")
.. _datasette_allowed_resources_with_reasons:
The ``page.all()`` async generator automatically handles pagination, fetching additional pages and yielding all resources one at a time.
await .allowed_resources_with_reasons(action, actor=None)
---------------------------------------------------------
Returns a list of :class:`datasette.permissions.AllowedResource` tuples. Each tuple contains a ``Resource`` plus a list of strings describing the rules that granted access. This powers the debugging data shown by the ``/-/allowed`` endpoint and is helpful when building administrative tooling that needs to show why access was granted.
This method uses :ref:`datasette_allowed_resources_sql` under the hood and is an efficient way to list the databases, tables or other resources that an actor can access for a specific action.
.. _datasette_allowed_resources_sql: