Add datasette.ensure_permission() method, refs #2525, refs #2528

Implements a new ensure_permission() method that is a convenience wrapper
around allowed() that raises Forbidden instead of returning False.

Changes:
- Added ensure_permission() method to datasette/app.py
- Updated all views to use ensure_permission() instead of the pattern:
  if not await self.ds.allowed(...): raise Forbidden(...)
- Updated docs/internals.rst to document the new method
- Removed old ensure_permissions() documentation (that method was already removed)

The new method simplifies permission enforcement in views and makes the
code more concise and consistent.
This commit is contained in:
Simon Willison 2025-10-25 09:28:33 -07:00
commit fabcfd68ad
5 changed files with 74 additions and 45 deletions

View file

@ -1310,6 +1310,39 @@ class Datasette:
return result
async def ensure_permission(
self,
*,
action: str,
resource: "Resource" = None,
actor: dict | None = None,
):
"""
Check if actor can perform action on resource, raising Forbidden if not.
This is a convenience wrapper around allowed() that raises Forbidden
instead of returning False. Use this when you want to enforce a permission
check and halt execution if it fails.
Example:
from datasette.resources import TableResource
# Will raise Forbidden if actor cannot view the table
await datasette.ensure_permission(
action="view-table",
resource=TableResource(database="analytics", table="users"),
actor=request.actor
)
# For instance-level actions, resource can be omitted:
await datasette.ensure_permission(
action="permissions-debug",
actor=request.actor
)
"""
if not await self.allowed(action=action, resource=resource, actor=actor):
raise Forbidden(action)
async def execute(
self,
db_name,

View file

@ -370,12 +370,11 @@ async def database_download(request, datasette):
from datasette.resources import DatabaseResource
database = tilde_decode(request.url_vars["database"])
if not await datasette.allowed(
await datasette.ensure_permission(
action="view-database-download",
resource=DatabaseResource(database=database),
actor=request.actor,
):
raise Forbidden("view-database-download")
)
try:
db = datasette.get_database(route=database)
except KeyError:
@ -544,12 +543,11 @@ class QueryView(View):
raise Forbidden("You do not have permission to view this query")
else:
if not await datasette.allowed(
await datasette.ensure_permission(
action="execute-sql",
resource=DatabaseResource(database=database),
actor=request.actor,
):
raise Forbidden("execute-sql")
)
# Flattened because of ?sql=&name1=value1&name2=value2 feature
params = {key: request.args.get(key) for key in request.args}

View file

@ -26,8 +26,7 @@ class IndexView(BaseView):
async def get(self, request):
as_format = request.url_vars["format"]
if not await self.ds.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
# Get all allowed databases and tables in bulk
allowed_databases = await self.ds.allowed_resources(

View file

@ -44,8 +44,7 @@ class JsonDataView(BaseView):
async def get(self, request):
if self.permission:
if not await self.ds.allowed(action=self.permission, actor=request.actor):
raise Forbidden(self.permission)
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
if self.needs_request:
data = self.data_callback(request)
else:
@ -55,8 +54,7 @@ class JsonDataView(BaseView):
class PatternPortfolioView(View):
async def get(self, request, datasette):
if not await datasette.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
await datasette.ensure_permission(action="view-instance", actor=request.actor)
return Response.html(
await datasette.render_template(
"patterns.html",
@ -114,10 +112,8 @@ class PermissionsDebugView(BaseView):
has_json_alternate = False
async def get(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
if not await self.ds.allowed(action="permissions-debug", actor=request.actor):
raise Forbidden("Permission denied")
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
filter_ = request.args.get("filter") or "all"
permission_checks = list(reversed(self.ds._permission_checks))
if filter_ == "exclude-yours":
@ -153,10 +149,8 @@ class PermissionsDebugView(BaseView):
)
async def post(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
if not await self.ds.allowed(action="permissions-debug", actor=request.actor):
raise Forbidden("Permission denied")
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
vars = await request.post_vars()
actor = json.loads(vars["actor"])
permission = vars["permission"]
@ -362,10 +356,8 @@ class PermissionRulesView(BaseView):
has_json_alternate = False
async def get(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
if not await self.ds.allowed(action="permissions-debug", actor=request.actor):
raise Forbidden("Permission denied")
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
# Check if this is a request for JSON (has .json extension)
as_format = request.url_vars.get("format")
@ -607,13 +599,11 @@ class MessagesDebugView(BaseView):
has_json_alternate = False
async def get(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
return await self.render(["messages_debug.html"], request)
async def post(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor):
raise Forbidden("view-instance")
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
post = await request.post_vars()
message = post.get("message", "")
message_type = post.get("message_type") or "INFO"

View file

@ -416,30 +416,39 @@ The method returns ``True`` if the permission is granted, ``False`` if denied.
For legacy string/tuple based permission checking, use :ref:`datasette_permission_allowed` instead.
.. _datasette_ensure_permissions:
.. _datasette_ensure_permission:
await .ensure_permissions(actor, permissions)
---------------------------------------------
await .ensure_permission(action, resource=None, actor=None)
------------------------------------------------------------
``actor`` - dictionary
``action`` - string
The action to check. See :ref:`permissions` for a list of available actions.
``resource`` - Resource object (optional)
The resource to check the permission against. Must be an instance of ``InstanceResource``, ``DatabaseResource``, or ``TableResource`` from the ``datasette.resources`` module. If omitted, defaults to ``InstanceResource()`` for instance-level permissions.
``actor`` - dictionary (optional)
The authenticated actor. This is usually ``request.actor``.
``permissions`` - list
A list of permissions to check. Each permission in that list can be a string ``action`` name or a 2-tuple of ``(action, resource)``.
This is a convenience wrapper around :ref:`datasette_allowed` that raises a ``datasette.Forbidden`` exception if the permission check fails. Use this when you want to enforce a permission check and halt execution if the actor is not authorized.
This method allows multiple permissions to be checked at once. It raises a ``datasette.Forbidden`` exception if any of the checks are denied before one of them is explicitly granted.
This is useful when you need to check multiple permissions at once. For example, an actor should be able to view a table if either one of the following checks returns ``True`` or not a single one of them returns ``False``:
Example:
.. code-block:: python
await datasette.ensure_permissions(
request.actor,
[
("view-table", (database, table)),
("view-database", database),
"view-instance",
],
from datasette.resources import TableResource
# Will raise Forbidden if actor cannot view the table
await datasette.ensure_permission(
action="view-table",
resource=TableResource(database="fixtures", table="cities"),
actor=request.actor
)
# For instance-level actions, resource can be omitted:
await datasette.ensure_permission(
action="permissions-debug",
actor=request.actor
)
.. _datasette_check_visibility:
@ -473,7 +482,7 @@ This example checks if the user can access a specific table, and sets ``private`
resource=(database, table),
)
The following example runs three checks in a row, similar to :ref:`datasette_ensure_permissions`. If any of the checks are denied before one of them is explicitly granted then ``visible`` will be ``False``. ``private`` will be ``True`` if an anonymous user would not be able to view the resource.
The following example runs three checks in a row. If any of the checks are denied before one of them is explicitly granted then ``visible`` will be ``False``. ``private`` will be ``True`` if an anonymous user would not be able to view the resource.
.. code-block:: python