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 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( async def execute(
self, self,
db_name, db_name,

View file

@ -370,12 +370,11 @@ async def database_download(request, datasette):
from datasette.resources import DatabaseResource from datasette.resources import DatabaseResource
database = tilde_decode(request.url_vars["database"]) database = tilde_decode(request.url_vars["database"])
if not await datasette.allowed( await datasette.ensure_permission(
action="view-database-download", action="view-database-download",
resource=DatabaseResource(database=database), resource=DatabaseResource(database=database),
actor=request.actor, actor=request.actor,
): )
raise Forbidden("view-database-download")
try: try:
db = datasette.get_database(route=database) db = datasette.get_database(route=database)
except KeyError: except KeyError:
@ -544,12 +543,11 @@ class QueryView(View):
raise Forbidden("You do not have permission to view this query") raise Forbidden("You do not have permission to view this query")
else: else:
if not await datasette.allowed( await datasette.ensure_permission(
action="execute-sql", action="execute-sql",
resource=DatabaseResource(database=database), resource=DatabaseResource(database=database),
actor=request.actor, actor=request.actor,
): )
raise Forbidden("execute-sql")
# Flattened because of ?sql=&name1=value1&name2=value2 feature # Flattened because of ?sql=&name1=value1&name2=value2 feature
params = {key: request.args.get(key) for key in request.args} 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): async def get(self, request):
as_format = request.url_vars["format"] as_format = request.url_vars["format"]
if not await self.ds.allowed(action="view-instance", actor=request.actor): await self.ds.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance")
# Get all allowed databases and tables in bulk # Get all allowed databases and tables in bulk
allowed_databases = await self.ds.allowed_resources( allowed_databases = await self.ds.allowed_resources(

View file

@ -44,8 +44,7 @@ class JsonDataView(BaseView):
async def get(self, request): async def get(self, request):
if self.permission: if self.permission:
if not await self.ds.allowed(action=self.permission, actor=request.actor): await self.ds.ensure_permission(action=self.permission, actor=request.actor)
raise Forbidden(self.permission)
if self.needs_request: if self.needs_request:
data = self.data_callback(request) data = self.data_callback(request)
else: else:
@ -55,8 +54,7 @@ class JsonDataView(BaseView):
class PatternPortfolioView(View): class PatternPortfolioView(View):
async def get(self, request, datasette): async def get(self, request, datasette):
if not await datasette.allowed(action="view-instance", actor=request.actor): await datasette.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance")
return Response.html( return Response.html(
await datasette.render_template( await datasette.render_template(
"patterns.html", "patterns.html",
@ -114,10 +112,8 @@ class PermissionsDebugView(BaseView):
has_json_alternate = False has_json_alternate = False
async def get(self, request): async def get(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor): await self.ds.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance") await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
if not await self.ds.allowed(action="permissions-debug", actor=request.actor):
raise Forbidden("Permission denied")
filter_ = request.args.get("filter") or "all" filter_ = request.args.get("filter") or "all"
permission_checks = list(reversed(self.ds._permission_checks)) permission_checks = list(reversed(self.ds._permission_checks))
if filter_ == "exclude-yours": if filter_ == "exclude-yours":
@ -153,10 +149,8 @@ class PermissionsDebugView(BaseView):
) )
async def post(self, request): async def post(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor): await self.ds.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance") await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
if not await self.ds.allowed(action="permissions-debug", actor=request.actor):
raise Forbidden("Permission denied")
vars = await request.post_vars() vars = await request.post_vars()
actor = json.loads(vars["actor"]) actor = json.loads(vars["actor"])
permission = vars["permission"] permission = vars["permission"]
@ -362,10 +356,8 @@ class PermissionRulesView(BaseView):
has_json_alternate = False has_json_alternate = False
async def get(self, request): async def get(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor): await self.ds.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance") await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
if not await self.ds.allowed(action="permissions-debug", actor=request.actor):
raise Forbidden("Permission denied")
# Check if this is a request for JSON (has .json extension) # Check if this is a request for JSON (has .json extension)
as_format = request.url_vars.get("format") as_format = request.url_vars.get("format")
@ -607,13 +599,11 @@ class MessagesDebugView(BaseView):
has_json_alternate = False has_json_alternate = False
async def get(self, request): async def get(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor): await self.ds.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance")
return await self.render(["messages_debug.html"], request) return await self.render(["messages_debug.html"], request)
async def post(self, request): async def post(self, request):
if not await self.ds.allowed(action="view-instance", actor=request.actor): await self.ds.ensure_permission(action="view-instance", actor=request.actor)
raise Forbidden("view-instance")
post = await request.post_vars() post = await request.post_vars()
message = post.get("message", "") message = post.get("message", "")
message_type = post.get("message_type") or "INFO" 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. 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``. The authenticated actor. This is usually ``request.actor``.
``permissions`` - list 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.
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 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. Example:
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``:
.. code-block:: python .. code-block:: python
await datasette.ensure_permissions( from datasette.resources import TableResource
request.actor,
[ # Will raise Forbidden if actor cannot view the table
("view-table", (database, table)), await datasette.ensure_permission(
("view-database", database), action="view-table",
"view-instance", 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: .. _datasette_check_visibility:
@ -473,7 +482,7 @@ This example checks if the user can access a specific table, and sets ``private`
resource=(database, table), 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 .. code-block:: python