diff --git a/backend/platform_api/permissions.py b/backend/platform_api/permissions.py index 12f23e63d5..32d6de2f33 100644 --- a/backend/platform_api/permissions.py +++ b/backend/platform_api/permissions.py @@ -3,6 +3,8 @@ from account_v2.authentication_controller import AuthenticationController from rest_framework.permissions import BasePermission +from platform_api.models import ApiKeyPermission + logger = logging.getLogger(__name__) @@ -21,3 +23,43 @@ def has_permission(self, request, view): except Exception: logger.exception("Error checking admin role for user %s", request.user.id) return False + + +class CanRotatePlatformApiKey(BasePermission): + """Permission for the ``rotate`` action (UN-3586). + + - **Session callers** must be organization admins (an admin may rotate any + key in their org). + - **Platform API key callers** must present a ``full_access`` key — the + API/automation path that the admin-only ``IsOrganizationAdmin`` gate + otherwise blocks (it rejects service accounts). ``full_access`` is the + admin-equivalent tier (it already permits DELETE), so key management + belongs there. + + Why ``full_access`` only (not any bearer key): ``rotate`` returns the new + key value in its response, so allowing a lower-tier ``read_write`` key to + rotate a ``full_access`` key would let it read that key's new secret and + escalate its privileges. Requiring ``full_access`` closes that path — a + ``full_access`` caller rotating any key gains no privilege (it is already + the top tier), matching what a session admin can do. + + Rotation stays confined to the caller's own organization via the auth + middleware (URL org must match the key's org) and the org-scoped queryset. + """ + + message = ( + "Rotating platform API keys requires an organization admin session or " + "a full_access platform API key." + ) + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + platform_key = getattr(request, "platform_api_key", None) + if platform_key is not None: + # Key-based caller: only a full_access key may rotate. Prevents a + # read_write key from rotating a full_access key and reading its new + # secret from the response (privilege escalation) — see docstring. + return platform_key.permission == ApiKeyPermission.FULL_ACCESS + # Session caller: must be an org admin (may rotate any key in the org). + return IsOrganizationAdmin().has_permission(request, view) diff --git a/backend/platform_api/views.py b/backend/platform_api/views.py index 2abf49812e..6886e51ddf 100644 --- a/backend/platform_api/views.py +++ b/backend/platform_api/views.py @@ -7,7 +7,7 @@ from utils.user_context import UserContext from platform_api.models import PlatformApiKey -from platform_api.permissions import IsOrganizationAdmin +from platform_api.permissions import CanRotatePlatformApiKey, IsOrganizationAdmin from platform_api.serializers import ( PlatformApiKeyCreateSerializer, PlatformApiKeyDetailSerializer, @@ -20,6 +20,13 @@ class PlatformApiKeyViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, IsOrganizationAdmin] + def get_permissions(self): + # Rotate allows self-service rotation by a platform API key (UN-3586); + # all other key-management actions stay admin/session-only. + if self.action == "rotate": + return [IsAuthenticated(), CanRotatePlatformApiKey()] + return super().get_permissions() + def get_queryset(self): return PlatformApiKey.objects.filter( organization=UserContext.get_organization(),