From baf94156dde40f15e220ae6a1c0c4f5dd02a1c72 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:03:31 +0530 Subject: [PATCH 1/4] UN-3586 [FEAT] Allow platform API key self-rotation via API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operational automation (RLDatix) needs credential rotation through the API, but the rotate endpoint was gated by IsOrganizationAdmin, which rejects service accounts — so a platform API key could not rotate itself. Add CanRotatePlatformApiKey for the rotate action: session callers still must be org admins (may rotate any key in the org), while a platform API key (bearer) may rotate ONLY its own key (pk == request.platform_api_key.id). Cross-org access remains impossible (auth middleware + org-scoped queryset); this only relaxes the intra-org gate to self-rotation. read keys still can't POST (middleware), so only read_write/full_access keys reach rotate. rotate returns the new key once via PlatformApiKeyDetailSerializer. No model or migration change. --- backend/platform_api/permissions.py | 31 +++++++++++++++++++++++++++++ backend/platform_api/views.py | 9 ++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/platform_api/permissions.py b/backend/platform_api/permissions.py index 12f23e63d5..a1f068655b 100644 --- a/backend/platform_api/permissions.py +++ b/backend/platform_api/permissions.py @@ -21,3 +21,34 @@ 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 (existing behavior — + an admin may rotate any key in their org). + - **Platform API key callers** (bearer/service-account sessions) may rotate + ONLY their own key, enabling self-service credential rotation via the API + without exposing other keys in the org. The org boundary is already + enforced upstream (auth middleware + org-scoped queryset); this adds the + intra-org "self only" restriction for key callers. + + Note: the auth middleware already blocks ``read`` keys from POST, so only + ``read_write``/``full_access`` keys can reach rotate. + """ + + message = ( + "You can only rotate your own API key. Rotating other keys requires an " + "organization admin." + ) + + 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: + # Platform API key caller: self-rotation only. + return str(view.kwargs.get("pk")) == str(platform_key.id) + # 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(), From cb0344530ea13f22abe567b57a37bc5a151e3493 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:20:24 +0530 Subject: [PATCH 2/4] UN-3586 [FIX] Move rotate self-check to has_object_permission (PR review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the 'key may rotate only its own key' constraint from has_permission (view-level, via view.kwargs[pk]) to has_object_permission, the idiomatic DRF location for per-object access control — it receives the already-fetched obj. has_permission stays as the coarse session-vs-key gate. Behavior is unchanged (self-rotate 200, cross-key 403): this permission is only used by the rotate detail action, which calls get_object() and so always triggers the object-level check. (Greptile) --- backend/platform_api/permissions.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/platform_api/permissions.py b/backend/platform_api/permissions.py index a1f068655b..177a9e1a53 100644 --- a/backend/platform_api/permissions.py +++ b/backend/platform_api/permissions.py @@ -34,6 +34,12 @@ class CanRotatePlatformApiKey(BasePermission): enforced upstream (auth middleware + org-scoped queryset); this adds the intra-org "self only" restriction for key callers. + The "self only" constraint for key callers is enforced object-level in + ``has_object_permission`` (the idiomatic DRF location); ``has_permission`` + is the coarse session-vs-key gate. This permission is only used by the + detail-route ``rotate`` action, which fetches the row via ``get_object()`` + and therefore always triggers the object-level check. + Note: the auth middleware already blocks ``read`` keys from POST, so only ``read_write``/``full_access`` keys can reach rotate. """ @@ -46,9 +52,17 @@ class CanRotatePlatformApiKey(BasePermission): 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: - # Platform API key caller: self-rotation only. - return str(view.kwargs.get("pk")) == str(platform_key.id) + if getattr(request, "platform_api_key", None) is not None: + # Platform API key caller: admitted here; the "own key only" + # constraint is enforced in has_object_permission below. + return True # Session caller: must be an org admin (may rotate any key in the org). return IsOrganizationAdmin().has_permission(request, view) + + def has_object_permission(self, request, view, obj): + platform_key = getattr(request, "platform_api_key", None) + if platform_key is not None: + # Platform API key may rotate only its own key. + return obj.id == platform_key.id + # Session admins were already gated in has_permission. + return True From 8305fa4ac82a637e06c79884ce1da564890774ab Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:52:00 +0530 Subject: [PATCH 3/4] UN-3586 [FIX] Allow key-based callers to rotate any key (drop self-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically confirmed on staging that rotating a platform API key via the API (bearer token) is blocked (403 'Only organization admins...') — the automation path UN-3586 asks for. Enable it: a platform API key caller may rotate, same as a session org admin. Drop the earlier self-only restriction (not a ticket requirement) and the now-unneeded has_object_permission. Org scoping (auth middleware + org-scoped queryset) still confines a key to its own org; read keys still can't POST (middleware). rotate already returns the new key. --- backend/platform_api/permissions.py | 37 ++++++++++------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/backend/platform_api/permissions.py b/backend/platform_api/permissions.py index 177a9e1a53..e0e3aacc24 100644 --- a/backend/platform_api/permissions.py +++ b/backend/platform_api/permissions.py @@ -28,41 +28,28 @@ class CanRotatePlatformApiKey(BasePermission): - **Session callers** must be organization admins (existing behavior — an admin may rotate any key in their org). - - **Platform API key callers** (bearer/service-account sessions) may rotate - ONLY their own key, enabling self-service credential rotation via the API - without exposing other keys in the org. The org boundary is already - enforced upstream (auth middleware + org-scoped queryset); this adds the - intra-org "self only" restriction for key callers. - - The "self only" constraint for key callers is enforced object-level in - ``has_object_permission`` (the idiomatic DRF location); ``has_permission`` - is the coarse session-vs-key gate. This permission is only used by the - detail-route ``rotate`` action, which fetches the row via ``get_object()`` - and therefore always triggers the object-level check. - - Note: the auth middleware already blocks ``read`` keys from POST, so only + - **Platform API key callers** (bearer/service-account sessions) are also + allowed to rotate — this is the API/automation path that the admin-only + ``IsOrganizationAdmin`` gate otherwise blocks (it rejects service + accounts). 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, so a key can only rotate keys in its own org. + + Note: the auth middleware blocks ``read`` keys from POST, so only ``read_write``/``full_access`` keys can reach rotate. """ message = ( - "You can only rotate your own API key. Rotating other keys requires an " - "organization admin." + "Rotating platform API keys requires an organization admin session " + "or a platform API key." ) def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False + # Platform API key (bearer) callers may rotate — the API/automation + # path that the admin-only IsOrganizationAdmin gate blocks. if getattr(request, "platform_api_key", None) is not None: - # Platform API key caller: admitted here; the "own key only" - # constraint is enforced in has_object_permission below. return True # Session caller: must be an org admin (may rotate any key in the org). return IsOrganizationAdmin().has_permission(request, view) - - def has_object_permission(self, request, view, obj): - platform_key = getattr(request, "platform_api_key", None) - if platform_key is not None: - # Platform API key may rotate only its own key. - return obj.id == platform_key.id - # Session admins were already gated in has_permission. - return True From 8e481c32c6e207d8d603a7d9551dbfad4e13fb7e Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:12:31 +0530 Subject: [PATCH 4/4] UN-3586 [FIX] Require full_access key to rotate (close privilege escalation) Greptile caught a real privilege escalation: after dropping self-only, a read_write bearer key could rotate a full_access key and read its new secret from the rotate response (rotate returns the new key), escalating read_write -> full_access. Fix: key-based callers must present a full_access key to rotate. read_write keys can no longer rotate; a full_access caller rotating any key gains no privilege (already top tier) and matches what a session admin can do. Session-admin rotate and org scoping unchanged. --- backend/platform_api/permissions.py | 44 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/backend/platform_api/permissions.py b/backend/platform_api/permissions.py index e0e3aacc24..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__) @@ -26,30 +28,38 @@ def has_permission(self, request, view): class CanRotatePlatformApiKey(BasePermission): """Permission for the ``rotate`` action (UN-3586). - - **Session callers** must be organization admins (existing behavior — - an admin may rotate any key in their org). - - **Platform API key callers** (bearer/service-account sessions) are also - allowed to rotate — this is the API/automation path that the admin-only - ``IsOrganizationAdmin`` gate otherwise blocks (it rejects service - accounts). 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, so a key can only rotate keys in its own org. - - Note: the auth middleware blocks ``read`` keys from POST, so only - ``read_write``/``full_access`` keys can reach rotate. + - **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 platform API key." + "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 API key (bearer) callers may rotate — the API/automation - # path that the admin-only IsOrganizationAdmin gate blocks. - if getattr(request, "platform_api_key", None) is not None: - return True + 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)