From fabcfd68add17a441418582a6486c73d1af439d3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 25 Oct 2025 09:28:33 -0700 Subject: [PATCH] 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. --- datasette/app.py | 33 ++++++++++++++++++++++++++++ datasette/views/database.py | 10 ++++----- datasette/views/index.py | 3 +-- datasette/views/special.py | 30 +++++++++----------------- docs/internals.rst | 43 ++++++++++++++++++++++--------------- 5 files changed, 74 insertions(+), 45 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index be90f188..cc388cb8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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, diff --git a/datasette/views/database.py b/datasette/views/database.py index b6e5d698..2fe93552 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -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} diff --git a/datasette/views/index.py b/datasette/views/index.py index 6e24120a..a5758cf8 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -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( diff --git a/datasette/views/special.py b/datasette/views/special.py index 7eaef02d..69abe8ba 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -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" diff --git a/docs/internals.rst b/docs/internals.rst index d0522e8a..56d5e0f9 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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