From b8c3c3df3a48bb5519d3c007511f34b1afdaa5b0 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 3 Oct 2025 01:18:43 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20minimal=20scope=20co?= =?UTF-8?q?ntrol=20for=20external=20API=20JWTs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce the principle of least privilege by granting viewset permissions only based on the scopes included in the token. JWTs should never be issued without controlling which actions the application is allowed to perform. The first and minimal scope is to allow creating a room link. Additional actions on the viewset will only be considered after this baseline scope is in place. --- src/backend/core/external_api/permissions.py | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/backend/core/external_api/permissions.py diff --git a/src/backend/core/external_api/permissions.py b/src/backend/core/external_api/permissions.py new file mode 100644 index 00000000..ec3d570a --- /dev/null +++ b/src/backend/core/external_api/permissions.py @@ -0,0 +1,76 @@ +"""Permission handlers for application-delegated API access.""" + +import logging +from typing import Dict + +from rest_framework import exceptions, permissions + +from .. import models + +logger = logging.getLogger(__name__) + + +class BaseScopePermission(permissions.BasePermission): + """Base class for scope-based permission checking. + + Subclasses must define `scope_map` attribute mapping actions to required scopes. + """ + + scope_map: Dict[str, str] = {} + + def has_permission(self, request, view): + """Check if the JWT token contains the required scope for this action. + + Args: + request: DRF request object with authenticated user + view: ViewSet instance + + Returns: + bool: True if permission granted + + Raises: + PermissionDenied: If required scope is missing from token + """ + # Get the current action (e.g., 'list', 'create') + action = getattr(view, "action", None) + if not action: + raise exceptions.PermissionDenied( + "Insufficient permissions. Unknown action." + ) + + required_scope = self.scope_map.get(action) + if not required_scope: + # Action not in scope_map, deny by default + raise exceptions.PermissionDenied( + f"Insufficient permissions. Required scope: {required_scope}" + ) + + token_payload = request.auth + token_scopes = token_payload.get("scope") + + if not token_scopes: + raise exceptions.PermissionDenied("Insufficient permissions.") + + # Ensure scopes is a list (handle both list and space-separated string) + if isinstance(token_scopes, str): + token_scopes = token_scopes.split() + + if required_scope not in token_scopes: + raise exceptions.PermissionDenied( + f"Insufficient permissions. Required scope: {required_scope}" + ) + + return True + + +class HasRequiredRoomScope(BaseScopePermission): + """Permission class for Room-related operations.""" + + scope_map = { + "list": models.ApplicationScope.ROOMS_LIST, + "retrieve": models.ApplicationScope.ROOMS_RETRIEVE, + "create": models.ApplicationScope.ROOMS_CREATE, + "update": models.ApplicationScope.ROOMS_UPDATE, + "partial_update": models.ApplicationScope.ROOMS_UPDATE, + "destroy": models.ApplicationScope.ROOMS_DELETE, + }