From 4ee988e426da0e7980a674096bebbf6b808f9723 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:48:04 +0530 Subject: [PATCH 01/10] UN-2407 [FEAT] Add global API deployment keys for grouped API deployments Add ability to generate a common API key that works across a group of API deployments, allowing users to manage shared access without creating individual keys per deployment. --- backend/api_v2/api_deployment_views.py | 7 +- backend/api_v2/deployment_helper.py | 36 +- backend/api_v2/dto.py | 1 + backend/api_v2/key_helper.py | 47 ++ backend/api_v2/serializers.py | 5 + backend/backend/settings/base.py | 1 + backend/backend/urls_v2.py | 1 + backend/global_api_deployment_key/__init__.py | 0 backend/global_api_deployment_key/apps.py | 6 + .../migrations/0001_initial.py | 94 ++++ .../migrations/__init__.py | 0 backend/global_api_deployment_key/models.py | 58 ++ .../global_api_deployment_key/permissions.py | 3 + .../global_api_deployment_key/serializers.py | 122 +++++ backend/global_api_deployment_key/urls.py | 28 + backend/global_api_deployment_key/views.py | 81 +++ backend/middleware/request_id.py | 10 + backend/pyproject.toml | 2 +- backend/utils/log_events.py | 2 +- backend/uv.lock | 11 +- .../navigations/side-nav-bar/SideNavBar.jsx | 10 + .../GlobalApiDeploymentKeys.css | 43 ++ .../GlobalApiDeploymentKeys.jsx | 510 ++++++++++++++++++ .../src/pages/GlobalApiDeploymentKeysPage.jsx | 7 + frontend/src/routes/useMainAppRoutes.js | 5 + 25 files changed, 1071 insertions(+), 19 deletions(-) create mode 100644 backend/global_api_deployment_key/__init__.py create mode 100644 backend/global_api_deployment_key/apps.py create mode 100644 backend/global_api_deployment_key/migrations/0001_initial.py create mode 100644 backend/global_api_deployment_key/migrations/__init__.py create mode 100644 backend/global_api_deployment_key/models.py create mode 100644 backend/global_api_deployment_key/permissions.py create mode 100644 backend/global_api_deployment_key/serializers.py create mode 100644 backend/global_api_deployment_key/urls.py create mode 100644 backend/global_api_deployment_key/views.py create mode 100644 frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css create mode 100644 frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx create mode 100644 frontend/src/pages/GlobalApiDeploymentKeysPage.jsx diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 47d0aac34d..3c07f623e7 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -74,7 +74,12 @@ def post( organization = api.organization serializer = ExecutionRequestSerializer( - data=request.data, context={"api": api, "api_key": api_key} + data=request.data, + context={ + "api": api, + "api_key": api_key, + "is_global_key": deployment_execution_dto.is_global_key, + }, ) serializer.is_valid(raise_exception=True) file_objs = serializer.validated_data.get(ApiExecution.FILES_FORM_DATA, []) diff --git a/backend/api_v2/deployment_helper.py b/backend/api_v2/deployment_helper.py index bfbff58b7b..fa7bded85b 100644 --- a/backend/api_v2/deployment_helper.py +++ b/backend/api_v2/deployment_helper.py @@ -31,6 +31,7 @@ InactiveAPI, InvalidAPIRequest, PresignedURLFetchError, + UnauthorizedKey, ) from api_v2.key_helper import KeyHelper from api_v2.models import APIDeployment, APIKey @@ -59,31 +60,48 @@ def validate_and_process( """Fetch API deployment and validate API key.""" api_name = kwargs.get("api_name") or request.data.get("api_name") api_deployment = DeploymentHelper.get_deployment_by_api_name(api_name=api_name) - DeploymentHelper.validate_api(api_deployment=api_deployment, api_key=api_key) + is_global_key = DeploymentHelper.validate_api( + api_deployment=api_deployment, api_key=api_key + ) deployment_execution_dto = DeploymentExecutionDTO( - api=api_deployment, api_key=api_key + api=api_deployment, api_key=api_key, is_global_key=is_global_key ) kwargs["deployment_execution_dto"] = deployment_execution_dto return func(self, request, *args, **kwargs) @staticmethod - def validate_api(api_deployment: APIDeployment | None, api_key: str) -> None: - """Validating API and API key. + def validate_api(api_deployment: APIDeployment | None, api_key: str) -> bool: + """Validate API deployment and API key. + + Tries deployment-specific key first. If that fails, falls back to + Global API Deployment Key validation. Args: - api_deployment (Optional[APIDeployment]): _description_ - api_key (str): _description_ + api_deployment: The API deployment instance + api_key: The bearer token value + + Returns: + bool: True if authenticated via Global API Deployment Key, False otherwise Raises: - APINotFound: _description_ - InactiveAPI: _description_ + APINotFound: If deployment not found + InactiveAPI: If deployment is inactive + UnauthorizedKey: If key validation fails """ if not api_deployment: raise APINotFound() if not api_deployment.is_active: raise InactiveAPI() - KeyHelper.validate_api_key(api_key=api_key, instance=api_deployment) + + try: + KeyHelper.validate_api_key(api_key=api_key, instance=api_deployment) + return False + except UnauthorizedKey: + KeyHelper.validate_global_api_deployment_key( + api_key=api_key, api_deployment=api_deployment + ) + return True @staticmethod def validate_and_get_workflow(workflow_id: str) -> Workflow: diff --git a/backend/api_v2/dto.py b/backend/api_v2/dto.py index 93df13e440..75b8c62775 100644 --- a/backend/api_v2/dto.py +++ b/backend/api_v2/dto.py @@ -9,3 +9,4 @@ class DeploymentExecutionDTO: api: APIDeployment api_key: str + is_global_key: bool = False diff --git a/backend/api_v2/key_helper.py b/backend/api_v2/key_helper.py index b1c9244ded..29ae8aa6d9 100644 --- a/backend/api_v2/key_helper.py +++ b/backend/api_v2/key_helper.py @@ -1,4 +1,8 @@ +from __future__ import annotations + import logging +import uuid as uuid_module +from typing import TYPE_CHECKING from django.core.exceptions import ValidationError from pipeline_v2.models import Pipeline @@ -7,6 +11,9 @@ from api_v2.exceptions import UnauthorizedKey from api_v2.models import APIDeployment, APIKey + +if TYPE_CHECKING: + from global_api_deployment_key.models import GlobalApiDeploymentKey from api_v2.serializers import APIKeySerializer logger = logging.getLogger(__name__) @@ -61,6 +68,46 @@ def has_access(api_key: APIKey, instance: APIDeployment | Pipeline) -> bool: return api_key.pipeline == instance return False + @staticmethod + def validate_global_api_deployment_key( + api_key: str, api_deployment: APIDeployment + ) -> GlobalApiDeploymentKey: + """Validate a Global API Deployment Key for deployment execution. + + Checks: + 1. Key exists and is active + 2. Key belongs to the same organization as the deployment + 3. Key has access to the specific deployment (allow_all or listed) + + Args: + api_key: The bearer token value + api_deployment: The API deployment being accessed + + Returns: + GlobalApiDeploymentKey: The validated key instance + + Raises: + UnauthorizedKey: If validation fails + """ + from global_api_deployment_key.models import GlobalApiDeploymentKey + + try: + key_uuid = uuid_module.UUID(api_key) + except (ValueError, AttributeError): + raise UnauthorizedKey() + + try: + global_key = GlobalApiDeploymentKey.objects.select_related( + "organization" + ).get(key=key_uuid, is_active=True) + except GlobalApiDeploymentKey.DoesNotExist: + raise UnauthorizedKey() + + if not global_key.has_access_to_deployment(api_deployment): + raise UnauthorizedKey() + + return global_key + @staticmethod def validate_workflow_exists(workflow_id: str) -> None: """Validate that the specified workflow_id exists in the Workflow diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 7c9a5a7696..ec76b8e3c9 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -381,6 +381,7 @@ def validate_llm_profile_id(self, value): # Get context from serializer api = self.context.get("api") api_key = self.context.get("api_key") + is_global_key = self.context.get("is_global_key", False) if not api or not api_key: raise ValidationError("Unable to validate LLM profile ownership") @@ -391,6 +392,10 @@ def validate_llm_profile_id(self, value): except ProfileManager.DoesNotExist: raise ValidationError("Profile not found") + # Global API Keys are org-level; skip per-user ownership check + if is_global_key: + return value + # Get the specific API key being used try: active_api_key = api.api_keys.get(api_key=api_key, is_active=True) diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index bb62960ae0..9a275d0952 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -345,6 +345,7 @@ def filter(self, record): "configuration", "dashboard_metrics", "platform_api", + "global_api_deployment_key", ) TENANT_APPS = [] diff --git a/backend/backend/urls_v2.py b/backend/backend/urls_v2.py index fd91d3cc2f..7c0ead02a8 100644 --- a/backend/backend/urls_v2.py +++ b/backend/backend/urls_v2.py @@ -65,4 +65,5 @@ path("execution/", include("workflow_manager.file_execution.urls")), path("metrics/", include("dashboard_metrics.urls")), path("platform-api/", include("platform_api.urls")), + path("global-api-deployment/", include("global_api_deployment_key.urls")), ] diff --git a/backend/global_api_deployment_key/__init__.py b/backend/global_api_deployment_key/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/global_api_deployment_key/apps.py b/backend/global_api_deployment_key/apps.py new file mode 100644 index 0000000000..1d39241676 --- /dev/null +++ b/backend/global_api_deployment_key/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GlobalApiDeploymentKeyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "global_api_deployment_key" diff --git a/backend/global_api_deployment_key/migrations/0001_initial.py b/backend/global_api_deployment_key/migrations/0001_initial.py new file mode 100644 index 0000000000..8c93e507b7 --- /dev/null +++ b/backend/global_api_deployment_key/migrations/0001_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 4.2.1 on 2026-03-25 18:22 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("account_v2", "0004_user_is_service_account"), + ("api_v2", "0003_add_organization_rate_limit"), + ] + + operations = [ + migrations.CreateModel( + name="GlobalApiDeploymentKey", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=128)), + ("description", models.TextField(max_length=512)), + ("key", models.UUIDField(default=uuid.uuid4, unique=True)), + ("is_active", models.BooleanField(default=True)), + ( + "allow_all_deployments", + models.BooleanField( + db_comment="If True, this key can authenticate any API deployment in the org", + default=True, + ), + ), + ( + "api_deployments", + models.ManyToManyField( + blank=True, + related_name="global_api_deployment_keys", + to="api_v2.apideployment", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="global_api_deployment_keys_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + db_comment="Foreign key reference to the Organization model.", + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="account_v2.organization", + ), + ), + ], + options={ + "db_table": "global_api_deployment_key", + }, + ), + migrations.AddConstraint( + model_name="globalapideploymentkey", + constraint=models.UniqueConstraint( + fields=("name", "organization"), + name="unique_global_api_deployment_key_name_per_org", + ), + ), + ] diff --git a/backend/global_api_deployment_key/migrations/__init__.py b/backend/global_api_deployment_key/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/global_api_deployment_key/models.py b/backend/global_api_deployment_key/models.py new file mode 100644 index 0000000000..4546558c4a --- /dev/null +++ b/backend/global_api_deployment_key/models.py @@ -0,0 +1,58 @@ +import uuid + +from account_v2.models import User +from api_v2.models import APIDeployment +from django.db import models +from utils.models.base_model import BaseModel +from utils.models.organization_mixin import DefaultOrganizationMixin + + +class GlobalApiDeploymentKey(DefaultOrganizationMixin, BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=128) + description = models.TextField(max_length=512) + key = models.UUIDField(default=uuid.uuid4, unique=True) + is_active = models.BooleanField(default=True) + allow_all_deployments = models.BooleanField( + default=True, + db_comment="If True, this key can authenticate any API deployment in the org", + ) + api_deployments = models.ManyToManyField( + APIDeployment, + blank=True, + related_name="global_api_deployment_keys", + ) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="global_api_deployment_keys_created", + ) + modified_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="+", + ) + + class Meta: + db_table = "global_api_deployment_key" + constraints = [ + models.UniqueConstraint( + fields=["name", "organization"], + name="unique_global_api_deployment_key_name_per_org", + ), + ] + + def __str__(self): + return f"{self.name} ({self.organization})" + + def has_access_to_deployment(self, api_deployment): + """Check if this key can authenticate the given API deployment.""" + if not self.is_active: + return False + if self.organization_id != api_deployment.organization_id: + return False + if self.allow_all_deployments: + return True + return self.api_deployments.filter(id=api_deployment.id).exists() diff --git a/backend/global_api_deployment_key/permissions.py b/backend/global_api_deployment_key/permissions.py new file mode 100644 index 0000000000..0d43cf34a4 --- /dev/null +++ b/backend/global_api_deployment_key/permissions.py @@ -0,0 +1,3 @@ +from platform_api.permissions import IsOrganizationAdmin + +__all__ = ["IsOrganizationAdmin"] diff --git a/backend/global_api_deployment_key/serializers.py b/backend/global_api_deployment_key/serializers.py new file mode 100644 index 0000000000..c251457a11 --- /dev/null +++ b/backend/global_api_deployment_key/serializers.py @@ -0,0 +1,122 @@ +import re + +from api_v2.models import APIDeployment +from rest_framework import serializers +from utils.user_context import UserContext + +from backend.serializers import AuditSerializer +from global_api_deployment_key.models import GlobalApiDeploymentKey + +SAFE_TEXT_PATTERN = re.compile(r"^[a-zA-Z0-9 \-_.,:()/]+$") +SAFE_TEXT_ERROR = ( + "Only alphanumeric characters, spaces, hyphens, underscores, " + "periods, commas, colons, parentheses, and forward slashes are allowed." +) + + +def validate_safe_text(value): + stripped = value.strip() + if not stripped: + raise serializers.ValidationError("This field cannot be empty.") + if not SAFE_TEXT_PATTERN.match(stripped): + raise serializers.ValidationError(SAFE_TEXT_ERROR) + return stripped + + +class ApiDeploymentMinimalSerializer(serializers.ModelSerializer): + """Minimal serializer for API deployments used in key assignment.""" + + class Meta: + model = APIDeployment + fields = ["id", "display_name", "api_name", "is_active"] + + +class GlobalApiDeploymentKeyListSerializer(serializers.ModelSerializer): + key = serializers.SerializerMethodField() + created_by_email = serializers.SerializerMethodField() + api_deployments = ApiDeploymentMinimalSerializer(many=True, read_only=True) + + class Meta: + model = GlobalApiDeploymentKey + fields = [ + "id", + "name", + "description", + "key", + "is_active", + "allow_all_deployments", + "api_deployments", + "created_at", + "modified_at", + "created_by_email", + ] + + def get_key(self, obj): + key_str = str(obj.key) + return f"****-{key_str[-4:]}" + + def get_created_by_email(self, obj): + return obj.created_by.email if obj.created_by else "Deleted user" + + +class GlobalApiDeploymentKeyDetailSerializer(serializers.ModelSerializer): + """Used for create/rotate responses where the full key is shown once.""" + + class Meta: + model = GlobalApiDeploymentKey + fields = ["id", "name", "key", "is_active"] + + +class GlobalApiDeploymentKeyCreateSerializer(AuditSerializer): + description = serializers.CharField(required=True, max_length=512) + api_deployments = serializers.PrimaryKeyRelatedField( + many=True, queryset=APIDeployment.objects.all(), required=False + ) + + class Meta: + model = GlobalApiDeploymentKey + fields = ["name", "description", "allow_all_deployments", "api_deployments"] + + def validate_name(self, value): + value = validate_safe_text(value) + organization = UserContext.get_organization() + if GlobalApiDeploymentKey.objects.filter( + name=value, organization=organization + ).exists(): + raise serializers.ValidationError( + "A key with this name already exists in your organization." + ) + return value + + def validate_description(self, value): + return validate_safe_text(value) + + +class GlobalApiDeploymentKeyUpdateSerializer(AuditSerializer): + api_deployments = serializers.PrimaryKeyRelatedField( + many=True, queryset=APIDeployment.objects.all(), required=False + ) + + class Meta: + model = GlobalApiDeploymentKey + fields = [ + "description", + "is_active", + "allow_all_deployments", + "api_deployments", + ] + extra_kwargs = { + "description": {"required": False}, + "is_active": {"required": False}, + "allow_all_deployments": {"required": False}, + } + + def validate_description(self, value): + return validate_safe_text(value) + + def update(self, instance, validated_data): + api_deployments = validated_data.pop("api_deployments", None) + instance = super().update(instance, validated_data) + if api_deployments is not None: + instance.api_deployments.set(api_deployments) + return instance diff --git a/backend/global_api_deployment_key/urls.py b/backend/global_api_deployment_key/urls.py new file mode 100644 index 0000000000..33185bed61 --- /dev/null +++ b/backend/global_api_deployment_key/urls.py @@ -0,0 +1,28 @@ +from django.urls import path + +from global_api_deployment_key.views import GlobalApiDeploymentKeyViewSet + +urlpatterns = [ + path( + "keys/", + GlobalApiDeploymentKeyViewSet.as_view({"get": "list", "post": "create"}), + name="global_api_deployment_key_list", + ), + path( + "keys//", + GlobalApiDeploymentKeyViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="global_api_deployment_key_detail", + ), + path( + "keys//rotate/", + GlobalApiDeploymentKeyViewSet.as_view({"post": "rotate"}), + name="global_api_deployment_key_rotate", + ), + path( + "deployments/", + GlobalApiDeploymentKeyViewSet.as_view({"get": "deployments"}), + name="global_api_deployment_key_deployments", + ), +] diff --git a/backend/global_api_deployment_key/views.py b/backend/global_api_deployment_key/views.py new file mode 100644 index 0000000000..b7e9a57393 --- /dev/null +++ b/backend/global_api_deployment_key/views.py @@ -0,0 +1,81 @@ +import uuid + +from api_v2.models import APIDeployment +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from utils.user_context import UserContext + +from global_api_deployment_key.models import GlobalApiDeploymentKey +from global_api_deployment_key.permissions import IsOrganizationAdmin +from global_api_deployment_key.serializers import ( + ApiDeploymentMinimalSerializer, + GlobalApiDeploymentKeyCreateSerializer, + GlobalApiDeploymentKeyDetailSerializer, + GlobalApiDeploymentKeyListSerializer, + GlobalApiDeploymentKeyUpdateSerializer, +) + + +class GlobalApiDeploymentKeyViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsOrganizationAdmin] + + def get_queryset(self): + return GlobalApiDeploymentKey.objects.filter( + organization=UserContext.get_organization(), + ).prefetch_related("api_deployments") + + def get_serializer_class(self): + if self.action == "list": + return GlobalApiDeploymentKeyListSerializer + if self.action == "create": + return GlobalApiDeploymentKeyCreateSerializer + if self.action == "partial_update": + return GlobalApiDeploymentKeyUpdateSerializer + return GlobalApiDeploymentKeyListSerializer + + def create(self, request, *args, **kwargs): + serializer = GlobalApiDeploymentKeyCreateSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + response_serializer = GlobalApiDeploymentKeyDetailSerializer(instance) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = GlobalApiDeploymentKeyListSerializer(instance) + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = GlobalApiDeploymentKeyUpdateSerializer( + instance, data=request.data, partial=True, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + updated_instance = serializer.save() + response_serializer = GlobalApiDeploymentKeyListSerializer(updated_instance) + return Response(response_serializer.data) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def rotate(self, request, *args, **kwargs): + instance = self.get_object() + instance.key = uuid.uuid4() + instance.modified_by = request.user + instance.save(update_fields=["key", "modified_by"]) + response_serializer = GlobalApiDeploymentKeyDetailSerializer(instance) + return Response(response_serializer.data) + + @action(detail=False, methods=["get"]) + def deployments(self, request): + """List all API deployments in the org for admins to assign to keys.""" + organization = UserContext.get_organization() + deployments = APIDeployment.objects.filter(organization=organization) + serializer = ApiDeploymentMinimalSerializer(deployments, many=True) + return Response(serializer.data) diff --git a/backend/middleware/request_id.py b/backend/middleware/request_id.py index c7d0bdb82a..f5a87a6aea 100644 --- a/backend/middleware/request_id.py +++ b/backend/middleware/request_id.py @@ -1,8 +1,18 @@ import uuid +from django.http import HttpRequest, HttpResponse from log_request_id.middleware import RequestIDMiddleware class CustomRequestIDMiddleware(RequestIDMiddleware): def _generate_id(self): return str(uuid.uuid4()) + + def process_response( + self, request: HttpRequest, response: HttpResponse + ) -> HttpResponse: + """Suppress verbose socket logs and normalize response codes.""" + if "/api/v1/socket" in request.path: + response.status_code = 200 + return response + return super().process_response(request, response) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 536b043329..c6a5a5f59a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "djangorestframework==3.14.0", "django-cors-headers==4.3.1", # Pinning django-celery-beat to avoid build issues - "django-celery-beat==2.5.0", + "django-celery-beat==2.6.0", "django-log-request-id>=2.1.0", "django-redis==5.4.0", "django-tenants==3.5.0", diff --git a/backend/utils/log_events.py b/backend/utils/log_events.py index f2ff725820..1a76822d22 100644 --- a/backend/utils/log_events.py +++ b/backend/utils/log_events.py @@ -134,5 +134,5 @@ def handle_user_logs(room: str, event: str, message: dict[str, Any]) -> None: def start_server(django_app: WSGIHandler, namespace: str) -> WSGIHandler: - django_app = socketio.WSGIApp(sio, django_app, socketio_path=namespace) + # django_app = socketio.WSGIApp(sio, django_app, socketio_path=namespace) return django_app diff --git a/backend/uv.lock b/backend/uv.lock index e2fc56b1b2..d0480d15e5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" [manifest] @@ -795,7 +795,7 @@ wheels = [ [[package]] name = "django-celery-beat" -version = "2.5.0" +version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "celery" }, @@ -805,10 +805,7 @@ dependencies = [ { name = "python-crontab" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/97/ca63898f76dd43fc91f4791b05dbbecb60dc99215f16b270e9b1e29af974/django-celery-beat-2.5.0.tar.gz", hash = "sha256:cd0a47f5958402f51ac0c715bc942ae33d7b50b4e48cba91bc3f2712be505df1", size = 159635, upload-time = "2023-03-14T10:02:10.9Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/92/fa53396870566276357bb81e3fece5b7f8a00f99c91689ff777c481d40e0/django_celery_beat-2.5.0-py3-none-any.whl", hash = "sha256:ae460faa5ea142fba0875409095d22f6bd7bcc7377889b85e8cab5c0dfb781fe", size = 97223, upload-time = "2023-03-14T10:02:00.093Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/1b/ce/308fdad8c073051c0a1e494939d5c304b4efbbeb4bee1115495a60c139e8/django-celery-beat-2.6.0.tar.gz", hash = "sha256:f75b2d129731f1214be8383e18fae6bfeacdb55dffb2116ce849222c0106f9ad", size = 160452, upload-time = "2024-03-03T17:07:03.864Z" } [[package]] name = "django-cors-headers" @@ -3754,7 +3751,7 @@ requires-dist = [ { name = "croniter", specifier = ">=3.0.3" }, { name = "cryptography", specifier = ">=41.0.7" }, { name = "django", specifier = "==4.2.1" }, - { name = "django-celery-beat", specifier = "==2.5.0" }, + { name = "django-celery-beat", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.3.1" }, { name = "django-filter", specifier = ">=24.3" }, { name = "django-log-request-id", specifier = ">=2.1.0" }, diff --git a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx index bb328b1a98..52861002cc 100644 --- a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx +++ b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx @@ -110,6 +110,11 @@ const getSettingsMenuItems = (orgName, isAdmin) => [ label: "Platform API Keys", path: `/${orgName}/settings/platform-api-keys`, }, + { + key: "globalApiDeploymentKeys", + label: "Global API Deployment Keys", + path: `/${orgName}/settings/global-api-deployment-keys`, + }, ] : []), { @@ -135,6 +140,9 @@ const getSettingsMenuItems = (orgName, isAdmin) => [ const getActiveSettingsKey = () => { const currentPath = globalThis.location.pathname; + if (currentPath.includes("/settings/global-api-deployment-keys")) { + return "globalApiDeploymentKeys"; + } if (currentPath.includes("/settings/platform-api-keys")) { return "platformApiKeys"; } @@ -457,6 +465,8 @@ const SideNavBar = ({ collapsed, setCollapsed }) => { globalThis.location.pathname === `/${orgName}/settings/platform` || globalThis.location.pathname === `/${orgName}/settings/platform-api-keys` || + globalThis.location.pathname === + `/${orgName}/settings/global-api-deployment-keys` || globalThis.location.pathname === `/${orgName}/settings/triad` || globalThis.location.pathname === `/${orgName}/settings/review` || globalThis.location.pathname === `/${orgName}/users`, diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css new file mode 100644 index 0000000000..6b6c8cc2e4 --- /dev/null +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css @@ -0,0 +1,43 @@ +/* Styles for GlobalApiDeploymentKeys */ + +.gadk__content { + padding: 20px; +} + +.gadk__header-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.gadk__key-cell { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; +} + +.gadk__key-text { + font-family: monospace; + font-size: 13px; + color: rgba(0, 0, 0, 0.65); +} + +.gadk__copy-icon { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); +} + +.gadk__key-cell:hover .gadk__copy-icon { + color: rgba(0, 0, 0, 0.88); +} + +.gadk__actions { + display: flex; + gap: 8px; +} + +.gadk__deployment-select { + width: 100%; + margin-top: 8px; +} diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx new file mode 100644 index 0000000000..945dcda46d --- /dev/null +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx @@ -0,0 +1,510 @@ +import { + ArrowLeftOutlined, + CopyOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + SyncOutlined, +} from "@ant-design/icons"; +import { + Button, + Checkbox, + Form, + Input, + Modal, + Select, + Switch, + Table, + Tag, + Tooltip, + Typography, +} from "antd"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; +import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; +import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; +import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx"; +import { useAlertStore } from "../../../store/alert-store"; +import { useSessionStore } from "../../../store/session-store"; +import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal.jsx"; +import { SettingsLayout } from "../settings-layout/SettingsLayout.jsx"; +import "../platform/PlatformSettings.css"; +import "./GlobalApiDeploymentKeys.css"; + +const SAFE_TEXT_REGEX = /^[a-zA-Z0-9 \-_.,:()/]+$/; +const SAFE_TEXT_MESSAGE = + "Only alphanumeric characters, spaces, hyphens, underscores, periods, commas, colons, parentheses, and forward slashes are allowed."; + +function GlobalApiDeploymentKeys() { + const [keys, setKeys] = useState([]); + const [deployments, setDeployments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); + + const { sessionDetails } = useSessionStore(); + const { setAlertDetails } = useAlertStore(); + const axiosPrivate = useAxiosPrivate(); + const navigate = useNavigate(); + const handleException = useExceptionHandler(); + const copyToClipboard = useCopyToClipboard(); + + const basePath = `/api/v1/unstract/${sessionDetails?.orgId}/global-api-deployment`; + + const fetchKeys = useCallback(() => { + if (!sessionDetails?.orgId) { + return; + } + setIsLoading(true); + axiosPrivate({ method: "GET", url: `${basePath}/keys/` }) + .then((res) => setKeys(res?.data || [])) + .catch((err) => + setAlertDetails( + handleException(err, "Failed to load Global API Deployment keys"), + ), + ) + .finally(() => setIsLoading(false)); + }, [basePath, sessionDetails?.orgId]); + + const fetchDeployments = useCallback(() => { + if (!sessionDetails?.orgId) { + return; + } + axiosPrivate({ method: "GET", url: `${basePath}/deployments/` }) + .then((res) => setDeployments(res?.data || [])) + .catch(() => { + /* Silent fail - deployments list is non-critical */ + }); + }, [basePath, sessionDetails?.orgId]); + + useEffect(() => { + fetchKeys(); + fetchDeployments(); + }, [fetchKeys, fetchDeployments]); + + const handleCreate = () => { + createForm.validateFields().then((values) => { + setIsSaving(true); + const payload = { + ...values, + allow_all_deployments: values?.allow_all_deployments ?? true, + api_deployments: + values?.allow_all_deployments === false + ? values?.api_deployments || [] + : [], + }; + axiosPrivate({ + method: "POST", + url: `${basePath}/keys/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: payload, + }) + .then((res) => { + setIsCreateModalOpen(false); + createForm.resetFields(); + fetchKeys(); + copyToClipboard(res?.data?.key, "API key"); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to create key")), + ) + .finally(() => setIsSaving(false)); + }); + }; + + const handleEdit = () => { + editForm.validateFields().then((values) => { + setIsSaving(true); + const payload = { + ...values, + api_deployments: + values?.allow_all_deployments === false + ? values?.api_deployments || [] + : [], + }; + axiosPrivate({ + method: "PATCH", + url: `${basePath}/keys/${selectedKey?.id}/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: payload, + }) + .then(() => { + setIsEditModalOpen(false); + editForm.resetFields(); + setSelectedKey(null); + fetchKeys(); + setAlertDetails({ + type: "success", + content: "Key updated successfully", + }); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to update key")), + ) + .finally(() => setIsSaving(false)); + }); + }; + + const handleToggleStatus = (record) => { + axiosPrivate({ + method: "PATCH", + url: `${basePath}/keys/${record?.id}/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: { is_active: !record?.is_active }, + }) + .then(() => fetchKeys()) + .catch((err) => + setAlertDetails(handleException(err, "Failed to update key status")), + ); + }; + + const handleRotate = (record) => { + axiosPrivate({ + method: "POST", + url: `${basePath}/keys/${record?.id}/rotate/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }) + .then((res) => { + fetchKeys(); + copyToClipboard(res?.data?.key, "API key"); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to rotate key")), + ); + }; + + const handleDelete = (record) => { + axiosPrivate({ + method: "DELETE", + url: `${basePath}/keys/${record?.id}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }) + .then(() => { + fetchKeys(); + setAlertDetails({ + type: "success", + content: "Key deleted successfully", + }); + }) + .catch((err) => + setAlertDetails(handleException(err, "Failed to delete key")), + ); + }; + + const openEditModal = (record) => { + setSelectedKey(record); + editForm.setFieldsValue({ + description: record?.description, + allow_all_deployments: record?.allow_all_deployments, + api_deployments: record?.api_deployments?.map((d) => d?.id) || [], + }); + setIsEditModalOpen(true); + }; + + const handleCopyKey = (record) => { + axiosPrivate({ method: "GET", url: `${basePath}/keys/${record?.id}/` }) + .then((res) => copyToClipboard(res?.data?.key, "API key")) + .catch((err) => + setAlertDetails(handleException(err, "Failed to copy key")), + ); + }; + + const formatDate = (dateStr) => { + if (!dateStr) { + return ""; + } + return new Date(dateStr).toLocaleString(); + }; + + const columns = [ + { + title: "Name", + dataIndex: "name", + key: "name", + width: "12%", + ellipsis: true, + }, + { + title: "Description", + dataIndex: "description", + key: "description", + width: "14%", + ellipsis: true, + }, + { + title: "API Key", + dataIndex: "key", + key: "key", + width: "12%", + render: (_, record) => ( + + + + ), + }, + { + title: "Scope", + key: "scope", + width: "16%", + render: (_, record) => + record?.allow_all_deployments ? ( + All Deployments + ) : ( + d?.display_name || d?.api_name) + ?.join(", ")} + > + {record?.api_deployments?.length || 0} deployment(s) + + ), + }, + { + title: "Active", + dataIndex: "is_active", + key: "is_active", + width: "7%", + render: (_, record) => ( + handleToggleStatus(record)} + /> + ), + }, + { + title: "Created By", + dataIndex: "created_by_email", + key: "created_by_email", + width: "14%", + ellipsis: true, + }, + { + title: "Created", + dataIndex: "created_at", + key: "created_at", + width: "12%", + render: (text) => formatDate(text), + }, + { + title: "Actions", + key: "actions", + width: "13%", + render: (_, record) => ( +
+ handleRotate(record)} + title="Rotate Key" + content="This will generate a new key and invalidate the current one. Continue?" + okText="Rotate" + > + +
+ ), + }, + ]; + + const DeploymentScopeFields = ({ form }) => { + const allowAll = Form.useWatch("allow_all_deployments", form); + return ( + <> + + Allow all API deployments + + + + + + ); + }; + + return ( + +
+
+ + + Global API Deployment Keys + +
+
+ +
+
+ +
+ + + + + + + {/* Create Modal */} + { + setIsCreateModalOpen(false); + createForm.resetFields(); + }} + okText="Create" + confirmLoading={isSaving} + centered + > +
+ + + + + + + + +
+ + {/* Edit Modal */} + { + setIsEditModalOpen(false); + editForm.resetFields(); + setSelectedKey(null); + }} + okText="Save" + confirmLoading={isSaving} + centered + > +
+ + + + + + + + +
+ + ); +} + +export { GlobalApiDeploymentKeys }; diff --git a/frontend/src/pages/GlobalApiDeploymentKeysPage.jsx b/frontend/src/pages/GlobalApiDeploymentKeysPage.jsx new file mode 100644 index 0000000000..9f589265c3 --- /dev/null +++ b/frontend/src/pages/GlobalApiDeploymentKeysPage.jsx @@ -0,0 +1,7 @@ +import { GlobalApiDeploymentKeys } from "../components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx"; + +function GlobalApiDeploymentKeysPage() { + return ; +} + +export { GlobalApiDeploymentKeysPage }; diff --git a/frontend/src/routes/useMainAppRoutes.js b/frontend/src/routes/useMainAppRoutes.js index 1d5849b103..91804d6d8d 100644 --- a/frontend/src/routes/useMainAppRoutes.js +++ b/frontend/src/routes/useMainAppRoutes.js @@ -11,6 +11,7 @@ import { AgencyPage } from "../pages/AgencyPage.jsx"; import ConnectorsPage from "../pages/ConnectorsPage.jsx"; import { CustomTools } from "../pages/CustomTools.jsx"; import { DeploymentsPage } from "../pages/DeploymentsPage.jsx"; +import { GlobalApiDeploymentKeysPage } from "../pages/GlobalApiDeploymentKeysPage.jsx"; import { InviteEditUserPage } from "../pages/InviteEditUserPage.jsx"; import { LogsPage } from "../pages/LogsPage.jsx"; import { MetricsDashboardPage } from "../pages/MetricsDashboardPage.jsx"; @@ -214,6 +215,10 @@ function useMainAppRoutes() { element={} /> + } + /> } /> {RequirePlatformAdmin && PlatformAdminPage && ( }> From 4f761fca922408908bdb7e3734e9994106444e82 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:06:40 +0530 Subject: [PATCH 02/10] UN-2407 [FIX] Address PR review comments for global API deployment keys - Revert unrelated changes: restore socketio WSGI wrapper in log_events.py and remove socket path middleware override - Security: scope api_deployments queryset to user's organization in both Create and Update serializers to prevent cross-org assignment - Move global-api-deployment-keys route inside RequireAdmin guard - Change allow_all_deployments default to False (least privilege) - Fix retrieve endpoint to return full key via DetailSerializer so copy-to-clipboard works with the actual key value - Include modified_at in rotate update_fields so timestamp updates --- .../migrations/0001_initial.py | 2 +- backend/global_api_deployment_key/models.py | 2 +- .../global_api_deployment_key/serializers.py | 18 ++++++++++++++++-- backend/global_api_deployment_key/views.py | 4 ++-- backend/middleware/request_id.py | 10 ---------- backend/utils/log_events.py | 2 +- frontend/src/routes/useMainAppRoutes.js | 8 ++++---- 7 files changed, 25 insertions(+), 21 deletions(-) diff --git a/backend/global_api_deployment_key/migrations/0001_initial.py b/backend/global_api_deployment_key/migrations/0001_initial.py index 8c93e507b7..f414613bfd 100644 --- a/backend/global_api_deployment_key/migrations/0001_initial.py +++ b/backend/global_api_deployment_key/migrations/0001_initial.py @@ -39,7 +39,7 @@ class Migration(migrations.Migration): "allow_all_deployments", models.BooleanField( db_comment="If True, this key can authenticate any API deployment in the org", - default=True, + default=False, ), ), ( diff --git a/backend/global_api_deployment_key/models.py b/backend/global_api_deployment_key/models.py index 4546558c4a..e1e5817811 100644 --- a/backend/global_api_deployment_key/models.py +++ b/backend/global_api_deployment_key/models.py @@ -14,7 +14,7 @@ class GlobalApiDeploymentKey(DefaultOrganizationMixin, BaseModel): key = models.UUIDField(default=uuid.uuid4, unique=True) is_active = models.BooleanField(default=True) allow_all_deployments = models.BooleanField( - default=True, + default=False, db_comment="If True, this key can authenticate any API deployment in the org", ) api_deployments = models.ManyToManyField( diff --git a/backend/global_api_deployment_key/serializers.py b/backend/global_api_deployment_key/serializers.py index c251457a11..da69a5d54d 100644 --- a/backend/global_api_deployment_key/serializers.py +++ b/backend/global_api_deployment_key/serializers.py @@ -70,13 +70,20 @@ class Meta: class GlobalApiDeploymentKeyCreateSerializer(AuditSerializer): description = serializers.CharField(required=True, max_length=512) api_deployments = serializers.PrimaryKeyRelatedField( - many=True, queryset=APIDeployment.objects.all(), required=False + many=True, queryset=APIDeployment.objects.none(), required=False ) class Meta: model = GlobalApiDeploymentKey fields = ["name", "description", "allow_all_deployments", "api_deployments"] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + organization = UserContext.get_organization() + self.fields["api_deployments"].queryset = APIDeployment.objects.filter( + organization=organization + ) + def validate_name(self, value): value = validate_safe_text(value) organization = UserContext.get_organization() @@ -94,9 +101,16 @@ def validate_description(self, value): class GlobalApiDeploymentKeyUpdateSerializer(AuditSerializer): api_deployments = serializers.PrimaryKeyRelatedField( - many=True, queryset=APIDeployment.objects.all(), required=False + many=True, queryset=APIDeployment.objects.none(), required=False ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + organization = UserContext.get_organization() + self.fields["api_deployments"].queryset = APIDeployment.objects.filter( + organization=organization + ) + class Meta: model = GlobalApiDeploymentKey fields = [ diff --git a/backend/global_api_deployment_key/views.py b/backend/global_api_deployment_key/views.py index b7e9a57393..84498e35f8 100644 --- a/backend/global_api_deployment_key/views.py +++ b/backend/global_api_deployment_key/views.py @@ -46,7 +46,7 @@ def create(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs): instance = self.get_object() - serializer = GlobalApiDeploymentKeyListSerializer(instance) + serializer = GlobalApiDeploymentKeyDetailSerializer(instance) return Response(serializer.data) def partial_update(self, request, *args, **kwargs): @@ -68,7 +68,7 @@ def rotate(self, request, *args, **kwargs): instance = self.get_object() instance.key = uuid.uuid4() instance.modified_by = request.user - instance.save(update_fields=["key", "modified_by"]) + instance.save(update_fields=["key", "modified_by", "modified_at"]) response_serializer = GlobalApiDeploymentKeyDetailSerializer(instance) return Response(response_serializer.data) diff --git a/backend/middleware/request_id.py b/backend/middleware/request_id.py index f5a87a6aea..c7d0bdb82a 100644 --- a/backend/middleware/request_id.py +++ b/backend/middleware/request_id.py @@ -1,18 +1,8 @@ import uuid -from django.http import HttpRequest, HttpResponse from log_request_id.middleware import RequestIDMiddleware class CustomRequestIDMiddleware(RequestIDMiddleware): def _generate_id(self): return str(uuid.uuid4()) - - def process_response( - self, request: HttpRequest, response: HttpResponse - ) -> HttpResponse: - """Suppress verbose socket logs and normalize response codes.""" - if "/api/v1/socket" in request.path: - response.status_code = 200 - return response - return super().process_response(request, response) diff --git a/backend/utils/log_events.py b/backend/utils/log_events.py index 1a76822d22..f2ff725820 100644 --- a/backend/utils/log_events.py +++ b/backend/utils/log_events.py @@ -134,5 +134,5 @@ def handle_user_logs(room: str, event: str, message: dict[str, Any]) -> None: def start_server(django_app: WSGIHandler, namespace: str) -> WSGIHandler: - # django_app = socketio.WSGIApp(sio, django_app, socketio_path=namespace) + django_app = socketio.WSGIApp(sio, django_app, socketio_path=namespace) return django_app diff --git a/frontend/src/routes/useMainAppRoutes.js b/frontend/src/routes/useMainAppRoutes.js index 91804d6d8d..8c2e1505ee 100644 --- a/frontend/src/routes/useMainAppRoutes.js +++ b/frontend/src/routes/useMainAppRoutes.js @@ -214,11 +214,11 @@ function useMainAppRoutes() { path="settings/platform-api-keys" element={} /> + } + /> - } - /> } /> {RequirePlatformAdmin && PlatformAdminPage && ( }> From dca558164133f84744d7fdab2d76edae67b3d39a Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:33:23 +0530 Subject: [PATCH 03/10] UN-2407 [FIX] Add admin permission message and hoist DeploymentScopeFields - permissions.py: subclass IsOrganizationAdmin with a clearer denial message - GlobalApiDeploymentKeys.jsx: move DeploymentScopeFields to module scope and add PropTypes --- .../global_api_deployment_key/permissions.py | 7 +- .../GlobalApiDeploymentKeys.jsx | 74 ++++++++++--------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/backend/global_api_deployment_key/permissions.py b/backend/global_api_deployment_key/permissions.py index 0d43cf34a4..5af1675ba0 100644 --- a/backend/global_api_deployment_key/permissions.py +++ b/backend/global_api_deployment_key/permissions.py @@ -1,3 +1,8 @@ -from platform_api.permissions import IsOrganizationAdmin +from platform_api.permissions import IsOrganizationAdmin as PlatformIsOrganizationAdmin + + +class IsOrganizationAdmin(PlatformIsOrganizationAdmin): + message = "Only organization admins can manage global API deployment keys." + __all__ = ["IsOrganizationAdmin"] diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx index 945dcda46d..2a2c59b990 100644 --- a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx @@ -19,6 +19,7 @@ import { Tooltip, Typography, } from "antd"; +import PropTypes from "prop-types"; import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -37,6 +38,43 @@ const SAFE_TEXT_REGEX = /^[a-zA-Z0-9 \-_.,:()/]+$/; const SAFE_TEXT_MESSAGE = "Only alphanumeric characters, spaces, hyphens, underscores, periods, commas, colons, parentheses, and forward slashes are allowed."; +function DeploymentScopeFields({ form, deployments }) { + const allowAll = Form.useWatch("allow_all_deployments", form); + return ( + <> + + Allow all API deployments + + + + + + ); +} + +DeploymentScopeFields.propTypes = { + form: PropTypes.object.isRequired, + deployments: PropTypes.array, +}; + function GlobalApiDeploymentKeys() { const [keys, setKeys] = useState([]); const [deployments, setDeployments] = useState([]); @@ -349,38 +387,6 @@ function GlobalApiDeploymentKeys() { }, ]; - const DeploymentScopeFields = ({ form }) => { - const allowAll = Form.useWatch("allow_all_deployments", form); - return ( - <> - - Allow all API deployments - - - - - - ); - }; - return (
@@ -463,7 +469,7 @@ function GlobalApiDeploymentKeys() { maxLength={512} /> - + @@ -500,7 +506,7 @@ function GlobalApiDeploymentKeys() { maxLength={512} /> - + From cb51b3cc65994025770f9f9d7ab1298353327bb7 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:07:12 +0530 Subject: [PATCH 04/10] UN-2407 [FIX] Address review comments: shared safe-text util + org-scope global-key profiles - Dedupe validate_safe_text/SAFE_TEXT_PATTERN into utils.input_sanitizer (was duplicated in platform_api and global_api_deployment_key serializers); add a docstring explaining the safe-char allow-list and why (reviewer ask) - api_v2: for global API keys, verify the llm_profile_id belongs to the deployment's organization before use (ProfileManager.objects is not org-scoped, so a caller could otherwise reference another org's profile). Generic 'Profile not found' avoids cross-org info disclosure (CodeRabbit) - Drop unrelated django-celery-beat 2.5.0->2.6.0 bump (reviewer ask) --- backend/api_v2/serializers.py | 15 ++++++++- .../global_api_deployment_key/serializers.py | 18 +---------- backend/platform_api/serializers.py | 22 +------------ backend/utils/input_sanitizer.py | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 39 deletions(-) diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 439d780f22..68fe379489 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -410,8 +410,21 @@ def validate_llm_profile_id(self, value): except ProfileManager.DoesNotExist: raise ValidationError("Profile not found") - # Global API Keys are org-level; skip per-user ownership check + # Global API Keys are org-level (not tied to a single user), so the + # per-user ownership check below does not apply. We must still confirm + # the profile belongs to the same organization as the deployment, + # otherwise a caller could reference another org's profile by UUID. + # ``ProfileManager.objects`` is not org-scoped by default, so this + # check is load-bearing, not merely defense-in-depth. if is_global_key: + profile_org_id = ( + profile.prompt_studio_tool.organization_id + if profile.prompt_studio_tool_id + else None + ) + if profile_org_id != api.organization_id: + # Generic error avoids confirming another org's profile exists. + raise ValidationError("Profile not found") return value # Get the specific API key being used diff --git a/backend/global_api_deployment_key/serializers.py b/backend/global_api_deployment_key/serializers.py index da69a5d54d..11837e97e3 100644 --- a/backend/global_api_deployment_key/serializers.py +++ b/backend/global_api_deployment_key/serializers.py @@ -1,27 +1,11 @@ -import re - from api_v2.models import APIDeployment from rest_framework import serializers +from utils.input_sanitizer import validate_safe_text from utils.user_context import UserContext from backend.serializers import AuditSerializer from global_api_deployment_key.models import GlobalApiDeploymentKey -SAFE_TEXT_PATTERN = re.compile(r"^[a-zA-Z0-9 \-_.,:()/]+$") -SAFE_TEXT_ERROR = ( - "Only alphanumeric characters, spaces, hyphens, underscores, " - "periods, commas, colons, parentheses, and forward slashes are allowed." -) - - -def validate_safe_text(value): - stripped = value.strip() - if not stripped: - raise serializers.ValidationError("This field cannot be empty.") - if not SAFE_TEXT_PATTERN.match(stripped): - raise serializers.ValidationError(SAFE_TEXT_ERROR) - return stripped - class ApiDeploymentMinimalSerializer(serializers.ModelSerializer): """Minimal serializer for API deployments used in key assignment.""" diff --git a/backend/platform_api/serializers.py b/backend/platform_api/serializers.py index 7962e75725..e3edadabd5 100644 --- a/backend/platform_api/serializers.py +++ b/backend/platform_api/serializers.py @@ -1,30 +1,10 @@ -import re - from rest_framework import serializers +from utils.input_sanitizer import validate_safe_text from utils.user_context import UserContext from backend.serializers import AuditSerializer from platform_api.models import PlatformApiKey -# Alphanumeric, spaces, hyphens, underscores, periods, commas, colons, -# apostrophes, parentheses, forward slashes. No HTML tags or angle brackets. -SAFE_TEXT_PATTERN = re.compile(r"^[a-zA-Z0-9 \-_.,:'()/]+$") -SAFE_TEXT_ERROR = ( - "Only alphanumeric characters, spaces, hyphens, underscores, " - "periods, commas, colons, apostrophes, parentheses, and forward slashes " - "are allowed." -) - - -def validate_safe_text(value): - """Reject HTML tags and restrict to safe characters.""" - stripped = value.strip() - if not stripped: - raise serializers.ValidationError("This field cannot be empty.") - if not SAFE_TEXT_PATTERN.match(stripped): - raise serializers.ValidationError(SAFE_TEXT_ERROR) - return stripped - class PlatformApiKeyListSerializer(serializers.ModelSerializer): key = serializers.SerializerMethodField() diff --git a/backend/utils/input_sanitizer.py b/backend/utils/input_sanitizer.py index 36772727e6..9271568dfd 100644 --- a/backend/utils/input_sanitizer.py +++ b/backend/utils/input_sanitizer.py @@ -43,3 +43,35 @@ def validate_name_field(value: str, field_name: str = "This field") -> str: if not value: raise ValidationError(f"{field_name} must not be empty.") return validate_no_html_tags(value, field_name) + + +# Allow-list of characters permitted in user-facing free text (names, +# descriptions). Unlike ``validate_no_html_tags`` (which block-lists known +# dangerous constructs), this is a strict allow-list: alphanumerics, spaces and +# a small set of common punctuation. Everything else is rejected so no HTML +# angle brackets (``<``/``>``), quotes, ampersands, backticks or other +# characters that could break out of an HTML attribute / start a tag / be +# abused for injection when the value is later rendered in non-React contexts +# (emails, PDFs, logs) can ever reach storage. +SAFE_TEXT_PATTERN = re.compile(r"^[a-zA-Z0-9 \-_.,:'()/]+$") +SAFE_TEXT_ERROR = ( + "Only alphanumeric characters, spaces, hyphens, underscores, " + "periods, commas, colons, apostrophes, parentheses, and forward slashes " + "are allowed." +) + + +def validate_safe_text(value: str) -> str: + """Restrict free text to the safe allow-list in ``SAFE_TEXT_PATTERN``. + + Strips surrounding whitespace, rejects empty/whitespace-only input, and + rejects any character outside the allow-list (which excludes ``<``, ``>``, + quotes, ``&`` and similar injection-prone characters). Returns the + stripped value on success. + """ + stripped = value.strip() + if not stripped: + raise ValidationError("This field cannot be empty.") + if not SAFE_TEXT_PATTERN.match(stripped): + raise ValidationError(SAFE_TEXT_ERROR) + return stripped From 4b759af0792965526d235b48b869f4994a62c8b4 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:34 +0530 Subject: [PATCH 05/10] UN-2407 [FIX] Add migration for editable=False on organization field The global_api_deployment_key app was branched before commit 64b06a7e1 marked DefaultOrganizationMixin.organization as editable=False (server- managed, never client input). After merging main, makemigrations detected model drift ('changes not yet reflected in a migration'). Add the matching AlterField migration, mirroring the same change already applied to api_v2, connector_v2, etc. --- ...ter_globalapideploymentkey_organization.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py diff --git a/backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py b/backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py new file mode 100644 index 0000000000..6f930ac66b --- /dev/null +++ b/backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py @@ -0,0 +1,23 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("global_api_deployment_key", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="globalapideploymentkey", + name="organization", + field=models.ForeignKey( + blank=True, + db_comment="Foreign key reference to the Organization model.", + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="account_v2.organization", + ), + ), + ] From 985d3db608bca44a6165c51d8ba0dbbe1cc97f76 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:42:08 +0530 Subject: [PATCH 06/10] UN-2407 [FIX] Deployment subset assignment + self-review fixes - serializers.py: assigning specific deployments to a global key (allow_all_deployments=False) failed with 'Invalid pk ... does not exist' for valid same-org deployments. DRF validates a many=True relation against child_relation.queryset; the code set .queryset on the ManyRelatedField wrapper (a no-op), leaving validation at APIDeployment.objects.none(). Set child_relation.queryset in both Create and Update serializers. Found via live API testing. - GlobalApiDeploymentKeys.jsx: restore apostrophe in SAFE_TEXT_REGEX/message to match backend allow-list; the copy dropped it, rejecting valid names client-side. - views.py get_serializer_class: remove unreachable create/partial_update branches. --- .../global_api_deployment_key/serializers.py | 20 +++++++++++++++++-- backend/global_api_deployment_key/views.py | 9 +++------ .../GlobalApiDeploymentKeys.jsx | 4 ++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/backend/global_api_deployment_key/serializers.py b/backend/global_api_deployment_key/serializers.py index 11837e97e3..96d25e5df4 100644 --- a/backend/global_api_deployment_key/serializers.py +++ b/backend/global_api_deployment_key/serializers.py @@ -64,7 +64,15 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) organization = UserContext.get_organization() - self.fields["api_deployments"].queryset = APIDeployment.objects.filter( + # For a many=True relation DRF validates incoming PKs against the + # *child* relation's queryset. Setting ``.queryset`` on the + # ManyRelatedField wrapper has no effect, leaving validation against the + # declared ``APIDeployment.objects.none()`` — which rejects every + # deployment ("Invalid pk ... object does not exist"). Scope the child + # relation's queryset so same-org deployments are accepted. + self.fields[ + "api_deployments" + ].child_relation.queryset = APIDeployment.objects.filter( organization=organization ) @@ -91,7 +99,15 @@ class GlobalApiDeploymentKeyUpdateSerializer(AuditSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) organization = UserContext.get_organization() - self.fields["api_deployments"].queryset = APIDeployment.objects.filter( + # For a many=True relation DRF validates incoming PKs against the + # *child* relation's queryset. Setting ``.queryset`` on the + # ManyRelatedField wrapper has no effect, leaving validation against the + # declared ``APIDeployment.objects.none()`` — which rejects every + # deployment ("Invalid pk ... object does not exist"). Scope the child + # relation's queryset so same-org deployments are accepted. + self.fields[ + "api_deployments" + ].child_relation.queryset = APIDeployment.objects.filter( organization=organization ) diff --git a/backend/global_api_deployment_key/views.py b/backend/global_api_deployment_key/views.py index 84498e35f8..4f02a2d8e4 100644 --- a/backend/global_api_deployment_key/views.py +++ b/backend/global_api_deployment_key/views.py @@ -27,12 +27,9 @@ def get_queryset(self): ).prefetch_related("api_deployments") def get_serializer_class(self): - if self.action == "list": - return GlobalApiDeploymentKeyListSerializer - if self.action == "create": - return GlobalApiDeploymentKeyCreateSerializer - if self.action == "partial_update": - return GlobalApiDeploymentKeyUpdateSerializer + # create/retrieve/partial_update/destroy/rotate are overridden and + # instantiate their serializers directly, so only ``list`` (and DRF's + # default fallback) routes through here — both use the list serializer. return GlobalApiDeploymentKeyListSerializer def create(self, request, *args, **kwargs): diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx index 2a2c59b990..abacc0d586 100644 --- a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx @@ -34,9 +34,9 @@ import { SettingsLayout } from "../settings-layout/SettingsLayout.jsx"; import "../platform/PlatformSettings.css"; import "./GlobalApiDeploymentKeys.css"; -const SAFE_TEXT_REGEX = /^[a-zA-Z0-9 \-_.,:()/]+$/; +const SAFE_TEXT_REGEX = /^[a-zA-Z0-9 \-_.,:'()/]+$/; const SAFE_TEXT_MESSAGE = - "Only alphanumeric characters, spaces, hyphens, underscores, periods, commas, colons, parentheses, and forward slashes are allowed."; + "Only alphanumeric characters, spaces, hyphens, underscores, periods, commas, colons, apostrophes, parentheses, and forward slashes are allowed."; function DeploymentScopeFields({ form, deployments }) { const allowAll = Form.useWatch("allow_all_deployments", form); From 06b077046e45bf97d673d46333efab79d5d85208 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:26:36 +0530 Subject: [PATCH 07/10] UN-2407 [FIX] Address human review: least-privilege default, scope validation, cleanups Frontend (GlobalApiDeploymentKeys.jsx): - BLOCKER: default 'Allow all API deployments' to unchecked and coerce to false (was checked / ?? true) so Create no longer silently mints an org-wide key; require >=1 deployment when not allow-all. Add .catch() to validateFields. Backend: - serializers: cross-field validate() rejecting incoherent scopes; fix DetailSerializer docstring (full key retrievable for lifetime, not once). - key_helper: hoist import (no circular dep), drop uuid alias, rely on UUIDField coercion instead of manual parse. - deployment_helper: log the deployment->global key auth fallback + which named global key authorized (audit visibility). - models: description TextField->CharField(512); type-annotate + note load-bearing org check on has_access_to_deployment. - migrations: squash 0002 into 0001 (app unreleased). --- backend/api_v2/deployment_helper.py | 13 +- backend/api_v2/key_helper.py | 20 +-- .../migrations/0001_initial.py | 4 +- ...ter_globalapideploymentkey_organization.py | 23 --- backend/global_api_deployment_key/models.py | 10 +- .../global_api_deployment_key/serializers.py | 52 ++++++- .../GlobalApiDeploymentKeys.jsx | 144 ++++++++++-------- 7 files changed, 160 insertions(+), 106 deletions(-) delete mode 100644 backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py diff --git a/backend/api_v2/deployment_helper.py b/backend/api_v2/deployment_helper.py index 1e4b6e2ae6..2ce83a3040 100644 --- a/backend/api_v2/deployment_helper.py +++ b/backend/api_v2/deployment_helper.py @@ -100,9 +100,20 @@ def validate_api(api_deployment: APIDeployment | None, api_key: str) -> bool: KeyHelper.validate_api_key(api_key=api_key, instance=api_deployment) return False except UnauthorizedKey: - KeyHelper.validate_global_api_deployment_key( + logger.debug( + "Deployment-specific key auth failed for API '%s'; falling back " + "to global API deployment key validation.", + api_deployment.api_name, + ) + global_key = KeyHelper.validate_global_api_deployment_key( api_key=api_key, api_deployment=api_deployment ) + logger.info( + "API '%s' authorized via global API deployment key '%s' (%s).", + api_deployment.api_name, + global_key.name, + global_key.id, + ) return True @staticmethod diff --git a/backend/api_v2/key_helper.py b/backend/api_v2/key_helper.py index 29ae8aa6d9..aec0c3d12e 100644 --- a/backend/api_v2/key_helper.py +++ b/backend/api_v2/key_helper.py @@ -1,19 +1,15 @@ from __future__ import annotations import logging -import uuid as uuid_module -from typing import TYPE_CHECKING from django.core.exceptions import ValidationError +from global_api_deployment_key.models import GlobalApiDeploymentKey from pipeline_v2.models import Pipeline from rest_framework.request import Request from workflow_manager.workflow_v2.workflow_helper import WorkflowHelper from api_v2.exceptions import UnauthorizedKey from api_v2.models import APIDeployment, APIKey - -if TYPE_CHECKING: - from global_api_deployment_key.models import GlobalApiDeploymentKey from api_v2.serializers import APIKeySerializer logger = logging.getLogger(__name__) @@ -89,18 +85,14 @@ def validate_global_api_deployment_key( Raises: UnauthorizedKey: If validation fails """ - from global_api_deployment_key.models import GlobalApiDeploymentKey - - try: - key_uuid = uuid_module.UUID(api_key) - except (ValueError, AttributeError): - raise UnauthorizedKey() - try: + # UUIDField coerces/validates the key string via to_python, raising + # ValidationError for a malformed value — the same pattern + # ``validate_api_key`` relies on, so no manual uuid parsing is needed. global_key = GlobalApiDeploymentKey.objects.select_related( "organization" - ).get(key=key_uuid, is_active=True) - except GlobalApiDeploymentKey.DoesNotExist: + ).get(key=api_key, is_active=True) + except (GlobalApiDeploymentKey.DoesNotExist, ValidationError): raise UnauthorizedKey() if not global_key.has_access_to_deployment(api_deployment): diff --git a/backend/global_api_deployment_key/migrations/0001_initial.py b/backend/global_api_deployment_key/migrations/0001_initial.py index f414613bfd..469960791b 100644 --- a/backend/global_api_deployment_key/migrations/0001_initial.py +++ b/backend/global_api_deployment_key/migrations/0001_initial.py @@ -32,7 +32,7 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=128)), - ("description", models.TextField(max_length=512)), + ("description", models.CharField(max_length=512)), ("key", models.UUIDField(default=uuid.uuid4, unique=True)), ("is_active", models.BooleanField(default=True)), ( @@ -73,7 +73,7 @@ class Migration(migrations.Migration): models.ForeignKey( blank=True, db_comment="Foreign key reference to the Organization model.", - default=None, + editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to="account_v2.organization", diff --git a/backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py b/backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py deleted file mode 100644 index 6f930ac66b..0000000000 --- a/backend/global_api_deployment_key/migrations/0002_alter_globalapideploymentkey_organization.py +++ /dev/null @@ -1,23 +0,0 @@ -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("global_api_deployment_key", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="globalapideploymentkey", - name="organization", - field=models.ForeignKey( - blank=True, - db_comment="Foreign key reference to the Organization model.", - editable=False, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="account_v2.organization", - ), - ), - ] diff --git a/backend/global_api_deployment_key/models.py b/backend/global_api_deployment_key/models.py index e1e5817811..05dd73061d 100644 --- a/backend/global_api_deployment_key/models.py +++ b/backend/global_api_deployment_key/models.py @@ -10,7 +10,9 @@ class GlobalApiDeploymentKey(DefaultOrganizationMixin, BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=128) - description = models.TextField(max_length=512) + # CharField (not TextField) so the 512 cap is a real varchar(512) DB + # invariant, not just a Python-layer serializer validator. + description = models.CharField(max_length=512) key = models.UUIDField(default=uuid.uuid4, unique=True) is_active = models.BooleanField(default=True) allow_all_deployments = models.BooleanField( @@ -47,10 +49,14 @@ class Meta: def __str__(self): return f"{self.name} ({self.organization})" - def has_access_to_deployment(self, api_deployment): + def has_access_to_deployment(self, api_deployment: APIDeployment) -> bool: """Check if this key can authenticate the given API deployment.""" if not self.is_active: return False + # Load-bearing, not defensive: validate_global_api_deployment_key looks + # the key up by ``key`` + ``is_active`` with NO org filter, so this is + # the only thing preventing a key from authenticating another org's + # deployment. if self.organization_id != api_deployment.organization_id: return False if self.allow_all_deployments: diff --git a/backend/global_api_deployment_key/serializers.py b/backend/global_api_deployment_key/serializers.py index 96d25e5df4..d508c2b988 100644 --- a/backend/global_api_deployment_key/serializers.py +++ b/backend/global_api_deployment_key/serializers.py @@ -7,6 +7,28 @@ from global_api_deployment_key.models import GlobalApiDeploymentKey +def _validate_deployment_scope(allow_all, deployments): + """Reject incoherent key scopes. + + - ``allow_all=True`` with an explicit deployment list — the list would be + silently ignored, so a caller thinking they scoped the key is wrong. + - ``allow_all=False`` with no deployments — a live key that authenticates + nothing (fails closed, but a support ticket waiting to happen). + """ + if allow_all and deployments: + raise serializers.ValidationError( + {"api_deployments": "Leave empty when 'allow all deployments' is enabled."} + ) + if not allow_all and not deployments: + raise serializers.ValidationError( + { + "api_deployments": ( + "Select at least one deployment or enable " "'allow all deployments'." + ) + } + ) + + class ApiDeploymentMinimalSerializer(serializers.ModelSerializer): """Minimal serializer for API deployments used in key assignment.""" @@ -44,7 +66,12 @@ def get_created_by_email(self, obj): class GlobalApiDeploymentKeyDetailSerializer(serializers.ModelSerializer): - """Used for create/rotate responses where the full key is shown once.""" + """Exposes the full plaintext key. + + Used by the create/rotate responses and by ``retrieve`` (GET keys//), + so the full key stays retrievable by an org admin for the key's lifetime — + consistent with the platform_api key flow (not a one-time reveal). + """ class Meta: model = GlobalApiDeploymentKey @@ -90,6 +117,13 @@ def validate_name(self, value): def validate_description(self, value): return validate_safe_text(value) + def validate(self, attrs): + _validate_deployment_scope( + attrs.get("allow_all_deployments", False), + attrs.get("api_deployments") or [], + ) + return attrs + class GlobalApiDeploymentKeyUpdateSerializer(AuditSerializer): api_deployments = serializers.PrimaryKeyRelatedField( @@ -128,6 +162,22 @@ class Meta: def validate_description(self, value): return validate_safe_text(value) + def validate(self, attrs): + # Partial updates may omit either field; fall back to the stored value + # so the effective scope is always validated as a coherent pair. + allow_all = attrs.get( + "allow_all_deployments", + self.instance.allow_all_deployments if self.instance else False, + ) + if "api_deployments" in attrs: + deployments = attrs["api_deployments"] or [] + elif self.instance is not None: + deployments = list(self.instance.api_deployments.all()) + else: + deployments = [] + _validate_deployment_scope(allow_all, deployments) + return attrs + def update(self, instance, validated_data): api_deployments = validated_data.pop("api_deployments", None) instance = super().update(instance, validated_data) diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx index abacc0d586..ce5ef4421f 100644 --- a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx @@ -45,11 +45,19 @@ function DeploymentScopeFields({ form, deployments }) { Allow all API deployments - +
+ + + + + + {/* Create Modal */} + { + setIsCreateModalOpen(false); + createForm.resetFields(); + }} + okText="Create" + confirmLoading={isSaving} + centered + > +
+ + + + + + + {renderCreateFields?.(createForm)} + +
+ + {/* Edit Modal */} + { + setIsEditModalOpen(false); + editForm.resetFields(); + setSelectedKey(null); + }} + okText="Save" + confirmLoading={isSaving} + centered + > +
+ + + + + + + {renderEditFields?.(editForm)} + +
+ + ); +} + +ApiKeyManager.propTypes = { + title: PropTypes.string.isRequired, + entityLabel: PropTypes.string.isRequired, + resourcePath: PropTypes.string.isRequired, + extraColumns: PropTypes.array, + renderCreateFields: PropTypes.func, + renderEditFields: PropTypes.func, + getEditInitialValues: PropTypes.func, + transformCreatePayload: PropTypes.func, + transformEditPayload: PropTypes.func, +}; + +export { ApiKeyManager }; diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css deleted file mode 100644 index 6b6c8cc2e4..0000000000 --- a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.css +++ /dev/null @@ -1,43 +0,0 @@ -/* Styles for GlobalApiDeploymentKeys */ - -.gadk__content { - padding: 20px; -} - -.gadk__header-actions { - display: flex; - justify-content: flex-end; - margin-bottom: 16px; -} - -.gadk__key-cell { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0; -} - -.gadk__key-text { - font-family: monospace; - font-size: 13px; - color: rgba(0, 0, 0, 0.65); -} - -.gadk__copy-icon { - font-size: 12px; - color: rgba(0, 0, 0, 0.45); -} - -.gadk__key-cell:hover .gadk__copy-icon { - color: rgba(0, 0, 0, 0.88); -} - -.gadk__actions { - display: flex; - gap: 8px; -} - -.gadk__deployment-select { - width: 100%; - margin-top: 8px; -} diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx index 34a0f607c9..b4c9483878 100644 --- a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx @@ -1,42 +1,12 @@ -import { - ArrowLeftOutlined, - CopyOutlined, - DeleteOutlined, - EditOutlined, - PlusOutlined, - SyncOutlined, -} from "@ant-design/icons"; -import { - Button, - Checkbox, - Form, - Input, - Modal, - Select, - Switch, - Table, - Tag, - Tooltip, - Typography, -} from "antd"; +import { Checkbox, Form, Select, Tag, Tooltip } from "antd"; import PropTypes from "prop-types"; import { useCallback, useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; -import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; -import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; -import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal.jsx"; -import { SettingsLayout } from "../settings-layout/SettingsLayout.jsx"; -import "../platform/PlatformSettings.css"; -import "./GlobalApiDeploymentKeys.css"; - -const SAFE_TEXT_REGEX = /^[a-zA-Z0-9 \-_.,:'()/]+$/; -const SAFE_TEXT_MESSAGE = - "Only alphanumeric characters, spaces, hyphens, underscores, periods, commas, colons, apostrophes, parentheses, and forward slashes are allowed."; +import { ApiKeyManager } from "../api-key-manager/ApiKeyManager.jsx"; function DeploymentScopeFields({ form, deployments }) { const allowAll = Form.useWatch("allow_all_deployments", form); @@ -62,7 +32,7 @@ function DeploymentScopeFields({ form, deployments }) { mode="multiple" placeholder="Search and select deployments" optionFilterProp="children" - className="gadk__deployment-select" + className="api-key-manager__deployment-select" showSearch disabled={allowAll !== false} > @@ -83,42 +53,38 @@ DeploymentScopeFields.propTypes = { deployments: PropTypes.array, }; -function GlobalApiDeploymentKeys() { - const [keys, setKeys] = useState([]); - const [deployments, setDeployments] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [selectedKey, setSelectedKey] = useState(null); - const [isSaving, setIsSaving] = useState(false); +const scopeColumn = { + title: "Scope", + key: "scope", + width: "14%", + render: (_, record) => + record?.allow_all_deployments ? ( + All Deployments + ) : ( + d?.display_name || d?.api_name) + ?.join(", ")} + > + {record?.api_deployments?.length || 0} deployment(s) + + ), +}; - const [createForm] = Form.useForm(); - const [editForm] = Form.useForm(); +// Scoped keys carry an explicit deployment list; org-wide keys must not (the +// backend rejects an incoherent pair). Mirror that shaping on the client. +const shapeApiDeployments = (values) => + values?.allow_all_deployments === false ? values?.api_deployments || [] : []; +function GlobalApiDeploymentKeys() { + const [deployments, setDeployments] = useState([]); const { sessionDetails } = useSessionStore(); const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); - const navigate = useNavigate(); const handleException = useExceptionHandler(); - const copyToClipboard = useCopyToClipboard(); const basePath = `/api/v1/unstract/${sessionDetails?.orgId}/global-api-deployment`; - const fetchKeys = useCallback(() => { - if (!sessionDetails?.orgId) { - return; - } - setIsLoading(true); - axiosPrivate({ method: "GET", url: `${basePath}/keys/` }) - .then((res) => setKeys(res?.data || [])) - .catch((err) => - setAlertDetails( - handleException(err, "Failed to load Global API Deployment keys"), - ), - ) - .finally(() => setIsLoading(false)); - }, [basePath, sessionDetails?.orgId]); - const fetchDeployments = useCallback(() => { if (!sessionDetails?.orgId) { return; @@ -133,403 +99,35 @@ function GlobalApiDeploymentKeys() { }, [basePath, sessionDetails?.orgId]); useEffect(() => { - fetchKeys(); fetchDeployments(); - }, [fetchKeys, fetchDeployments]); - - const handleCreate = () => { - createForm - .validateFields() - .then((values) => { - setIsSaving(true); - const payload = { - ...values, - allow_all_deployments: values?.allow_all_deployments ?? false, - api_deployments: - values?.allow_all_deployments === false - ? values?.api_deployments || [] - : [], - }; - axiosPrivate({ - method: "POST", - url: `${basePath}/keys/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - "Content-Type": "application/json", - }, - data: payload, - }) - .then((res) => { - setIsCreateModalOpen(false); - createForm.resetFields(); - fetchKeys(); - copyToClipboard(res?.data?.key, "API key"); - }) - .catch((err) => - setAlertDetails(handleException(err, "Failed to create key")), - ) - .finally(() => setIsSaving(false)); - }) - .catch(() => { - /* Invalid form: antd renders inline field errors; swallow the reject. */ - }); - }; - - const handleEdit = () => { - editForm - .validateFields() - .then((values) => { - setIsSaving(true); - const payload = { - ...values, - api_deployments: - values?.allow_all_deployments === false - ? values?.api_deployments || [] - : [], - }; - axiosPrivate({ - method: "PATCH", - url: `${basePath}/keys/${selectedKey?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - "Content-Type": "application/json", - }, - data: payload, - }) - .then(() => { - setIsEditModalOpen(false); - editForm.resetFields(); - setSelectedKey(null); - fetchKeys(); - setAlertDetails({ - type: "success", - content: "Key updated successfully", - }); - }) - .catch((err) => - setAlertDetails(handleException(err, "Failed to update key")), - ) - .finally(() => setIsSaving(false)); - }) - .catch(() => { - /* Invalid form: antd renders inline field errors; swallow the reject. */ - }); - }; - - const handleToggleStatus = (record) => { - axiosPrivate({ - method: "PATCH", - url: `${basePath}/keys/${record?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - "Content-Type": "application/json", - }, - data: { is_active: !record?.is_active }, - }) - .then(() => fetchKeys()) - .catch((err) => - setAlertDetails(handleException(err, "Failed to update key status")), - ); - }; + }, [fetchDeployments]); - const handleRotate = (record) => { - axiosPrivate({ - method: "POST", - url: `${basePath}/keys/${record?.id}/rotate/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, - }) - .then((res) => { - fetchKeys(); - copyToClipboard(res?.data?.key, "API key"); - }) - .catch((err) => - setAlertDetails(handleException(err, "Failed to rotate key")), - ); - }; - - const handleDelete = (record) => { - axiosPrivate({ - method: "DELETE", - url: `${basePath}/keys/${record?.id}/`, - headers: { "X-CSRFToken": sessionDetails?.csrfToken }, - }) - .then(() => { - fetchKeys(); - setAlertDetails({ - type: "success", - content: "Key deleted successfully", - }); - }) - .catch((err) => - setAlertDetails(handleException(err, "Failed to delete key")), - ); - }; - - const openEditModal = (record) => { - setSelectedKey(record); - editForm.setFieldsValue({ - description: record?.description, - allow_all_deployments: record?.allow_all_deployments, - api_deployments: record?.api_deployments?.map((d) => d?.id) || [], - }); - setIsEditModalOpen(true); - }; - - const handleCopyKey = (record) => { - axiosPrivate({ method: "GET", url: `${basePath}/keys/${record?.id}/` }) - .then((res) => copyToClipboard(res?.data?.key, "API key")) - .catch((err) => - setAlertDetails(handleException(err, "Failed to copy key")), - ); - }; - - const formatDate = (dateStr) => { - if (!dateStr) { - return ""; - } - return new Date(dateStr).toLocaleString(); - }; - - const columns = [ - { - title: "Name", - dataIndex: "name", - key: "name", - width: "12%", - ellipsis: true, - }, - { - title: "Description", - dataIndex: "description", - key: "description", - width: "14%", - ellipsis: true, - }, - { - title: "API Key", - dataIndex: "key", - key: "key", - width: "12%", - render: (_, record) => ( - - - - ), - }, - { - title: "Scope", - key: "scope", - width: "16%", - render: (_, record) => - record?.allow_all_deployments ? ( - All Deployments - ) : ( - d?.display_name || d?.api_name) - ?.join(", ")} - > - {record?.api_deployments?.length || 0} deployment(s) - - ), - }, - { - title: "Active", - dataIndex: "is_active", - key: "is_active", - width: "7%", - render: (_, record) => ( - handleToggleStatus(record)} - /> - ), - }, - { - title: "Created By", - dataIndex: "created_by_email", - key: "created_by_email", - width: "14%", - ellipsis: true, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - width: "12%", - render: (text) => formatDate(text), - }, - { - title: "Actions", - key: "actions", - width: "13%", - render: (_, record) => ( -
- handleRotate(record)} - title="Rotate Key" - content="This will generate a new key and invalidate the current one. Continue?" - okText="Rotate" - > - -
- ), - }, - ]; + const renderScopeFields = (form) => ( + + ); return ( - -
-
- - - Global API Deployment Keys - -
-
- -
-
- -
-
- - - - - - {/* Create Modal */} - { - setIsCreateModalOpen(false); - createForm.resetFields(); - }} - okText="Create" - confirmLoading={isSaving} - centered - > -
- - - - - - - - -
- - {/* Edit Modal */} - { - setIsEditModalOpen(false); - editForm.resetFields(); - setSelectedKey(null); - }} - okText="Save" - confirmLoading={isSaving} - centered - > -
- - - - - - - - -
- + ({ + allow_all_deployments: record?.allow_all_deployments, + api_deployments: record?.api_deployments?.map((d) => d?.id) || [], + })} + transformCreatePayload={(values) => ({ + ...values, + allow_all_deployments: values?.allow_all_deployments ?? false, + api_deployments: shapeApiDeployments(values), + })} + transformEditPayload={(values) => ({ + ...values, + api_deployments: shapeApiDeployments(values), + })} + /> ); } diff --git a/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.css b/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.css deleted file mode 100644 index 9941aa466e..0000000000 --- a/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.css +++ /dev/null @@ -1,38 +0,0 @@ -/* Styles for PlatformApiKeys */ - -.platform-api-keys__content { - padding: 20px; -} - -.platform-api-keys__header-actions { - display: flex; - justify-content: flex-end; - margin-bottom: 16px; -} - -.platform-api-keys__key-cell { - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; -} - -.platform-api-keys__key-text { - font-family: monospace; - font-size: 13px; - pointer-events: none; -} - -.platform-api-keys__copy-icon { - font-size: 12px; - color: rgba(0, 0, 0, 0.45); -} - -.platform-api-keys__key-cell:hover .platform-api-keys__copy-icon { - color: rgba(0, 0, 0, 0.88); -} - -.platform-api-keys__actions { - display: flex; - gap: 8px; -} diff --git a/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.jsx b/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.jsx index d578d12f65..d7bac1316f 100644 --- a/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.jsx +++ b/frontend/src/components/settings/platform-api-keys/PlatformApiKeys.jsx @@ -1,40 +1,6 @@ -import { - ArrowLeftOutlined, - CopyOutlined, - DeleteOutlined, - EditOutlined, - PlusOutlined, - SyncOutlined, -} from "@ant-design/icons"; -import { - Button, - Form, - Input, - Modal, - Select, - Switch, - Table, - Tag, - Tooltip, - Typography, -} from "antd"; -import { useCallback, useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Form, Select, Tag } from "antd"; -import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; -import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; -import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; -import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx"; -import { useAlertStore } from "../../../store/alert-store"; -import { useSessionStore } from "../../../store/session-store"; -import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal.jsx"; -import { SettingsLayout } from "../settings-layout/SettingsLayout.jsx"; -import "../platform/PlatformSettings.css"; -import "./PlatformApiKeys.css"; - -const SAFE_TEXT_REGEX = /^[a-zA-Z0-9 \-_.,:'()/]+$/; -const SAFE_TEXT_MESSAGE = - "Only alphanumeric characters, spaces, hyphens, underscores, periods, commas, colons, apostrophes, parentheses, and forward slashes are allowed."; +import { ApiKeyManager } from "../api-key-manager/ApiKeyManager.jsx"; const PERMISSION_OPTIONS = [ { value: "read_write", label: "Read/Write", color: "blue" }, @@ -48,443 +14,43 @@ const PERMISSION_CONFIG = Object.fromEntries( ]), ); -function PlatformApiKeys() { - const [keys, setKeys] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [selectedKey, setSelectedKey] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - const [createForm] = Form.useForm(); - const [editForm] = Form.useForm(); - - const { sessionDetails } = useSessionStore(); - const { setAlertDetails } = useAlertStore(); - const axiosPrivate = useAxiosPrivate(); - const navigate = useNavigate(); - const handleException = useExceptionHandler(); - const copyToClipboard = useCopyToClipboard(); - - const basePath = `/api/v1/unstract/${sessionDetails?.orgId}/platform-api`; - - const fetchKeys = useCallback(() => { - if (!sessionDetails?.orgId) { - return; - } - setIsLoading(true); - axiosPrivate({ - method: "GET", - url: `${basePath}/keys/`, - }) - .then((res) => { - setKeys(res?.data || []); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to load API keys")); - }) - .finally(() => { - setIsLoading(false); - }); - }, [basePath, sessionDetails?.orgId]); - - useEffect(() => { - fetchKeys(); - }, [fetchKeys]); - - const handleCreate = () => { - createForm.validateFields().then((values) => { - setIsSaving(true); - axiosPrivate({ - method: "POST", - url: `${basePath}/keys/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - "Content-Type": "application/json", - }, - data: values, - }) - .then((res) => { - setIsCreateModalOpen(false); - createForm.resetFields(); - fetchKeys(); - copyToClipboard(res?.data?.key, "API key"); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to create API key")); - }) - .finally(() => { - setIsSaving(false); - }); - }); - }; - - const handleEdit = () => { - editForm.validateFields().then((values) => { - setIsSaving(true); - axiosPrivate({ - method: "PATCH", - url: `${basePath}/keys/${selectedKey?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - "Content-Type": "application/json", - }, - data: values, - }) - .then(() => { - setIsEditModalOpen(false); - editForm.resetFields(); - setSelectedKey(null); - fetchKeys(); - setAlertDetails({ - type: "success", - content: "API key updated successfully", - }); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to update API key")); - }) - .finally(() => { - setIsSaving(false); - }); - }); - }; - - const handleToggleStatus = (record) => { - axiosPrivate({ - method: "PATCH", - url: `${basePath}/keys/${record?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - "Content-Type": "application/json", - }, - data: { is_active: !record?.is_active }, - }) - .then(() => { - fetchKeys(); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to update key status")); - }); - }; - - const handleRotate = (record) => { - axiosPrivate({ - method: "POST", - url: `${basePath}/keys/${record?.id}/rotate/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, - }) - .then((res) => { - fetchKeys(); - copyToClipboard(res?.data?.key, "API key"); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to rotate API key")); - }); - }; - - const handleDelete = (record) => { - axiosPrivate({ - method: "DELETE", - url: `${basePath}/keys/${record?.id}/`, - headers: { - "X-CSRFToken": sessionDetails?.csrfToken, - }, - }) - .then(() => { - fetchKeys(); - setAlertDetails({ - type: "success", - content: "API key deleted successfully", - }); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to delete API key")); - }); - }; - - const openEditModal = (record) => { - setSelectedKey(record); - editForm.setFieldsValue({ - description: record?.description, - permission: record?.permission, - }); - setIsEditModalOpen(true); - }; - - const handleCopyKey = (record) => { - axiosPrivate({ - method: "GET", - url: `${basePath}/keys/${record?.id}/`, - }) - .then((res) => { - copyToClipboard(res?.data?.key, "API key"); - }) - .catch((err) => { - setAlertDetails(handleException(err, "Failed to copy API key")); - }); - }; - - const formatDate = (dateStr) => { - if (!dateStr) { - return ""; - } - return new Date(dateStr).toLocaleString(); - }; - - const columns = [ - { - title: "Name", - dataIndex: "name", - key: "name", - width: "14%", - ellipsis: true, - }, - { - title: "Description", - dataIndex: "description", - key: "description", - width: "18%", - ellipsis: true, - }, - { - title: "API Key", - dataIndex: "key", - key: "key", - width: "14%", - render: (_, record) => ( - - - - ), - }, - { - title: "Permission", - dataIndex: "permission", - key: "permission", - width: "10%", - render: (text) => { - const { color, label } = PERMISSION_CONFIG[text] ?? { - color: "default", - label: `Unknown: ${text}`, - }; - return {label}; - }, - }, - { - title: "Active", - dataIndex: "is_active", - key: "is_active", - width: "7%", - render: (_, record) => ( - handleToggleStatus(record)} - /> - ), - }, - { - title: "Created By", - dataIndex: "created_by_email", - key: "created_by_email", - width: "17%", - ellipsis: true, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - width: "15%", - render: (text) => formatDate(text), - }, - { - title: "Actions", - key: "actions", - width: "15%", - render: (_, record) => ( -
- handleRotate(record)} - title="Rotate API Key" - content="This will generate a new key and invalidate the current one. Continue?" - okText="Rotate" - > - -
- ), - }, - ]; +const permissionColumn = { + title: "Permission", + dataIndex: "permission", + key: "permission", + width: "10%", + render: (text) => { + const { color, label } = PERMISSION_CONFIG[text] ?? { + color: "default", + label: `Unknown: ${text}`, + }; + return {label}; + }, +}; +function PlatformApiKeys() { return ( - -
-
- - - Platform API Keys - -
-
- -
-
- -
-
- - - - - - {/* Create Modal */} - { - setIsCreateModalOpen(false); - createForm.resetFields(); - }} - okText="Create" - confirmLoading={isSaving} - centered - > -
- - - - - - - - - - - - - - + + )} + renderEditFields={() => ( + +