diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 5416212896..f263a9529b 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 b6b3a8d16f..5fbec7999c 100644 --- a/backend/api_v2/deployment_helper.py +++ b/backend/api_v2/deployment_helper.py @@ -9,6 +9,7 @@ from configuration.models import Configuration from django.conf import settings from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile +from global_api_deployment_key.models import GlobalApiDeploymentKey from plugins.workflow_manager.workflow_v2.api_hub_usage_utils import APIHubUsageUtil from rest_framework.request import Request from rest_framework.serializers import Serializer @@ -33,6 +34,7 @@ InactiveAPI, InvalidAPIRequest, PresignedURLFetchError, + UnauthorizedKey, ) from api_v2.key_helper import KeyHelper from api_v2.models import APIDeployment, APIKey @@ -61,31 +63,63 @@ 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) + 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, global_key=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 + ) -> GlobalApiDeploymentKey | None: + """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: + The authorizing ``GlobalApiDeploymentKey`` when the request was + authenticated via a global key, else ``None`` (deployment-specific + key). Returning the key (not a bool) preserves audit identity. 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 None + except UnauthorizedKey: + 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 global_key @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..a9e311db96 100644 --- a/backend/api_v2/dto.py +++ b/backend/api_v2/dto.py @@ -1,7 +1,11 @@ from dataclasses import dataclass +from typing import TYPE_CHECKING from api_v2.models import APIDeployment +if TYPE_CHECKING: + from global_api_deployment_key.models import GlobalApiDeploymentKey + @dataclass class DeploymentExecutionDTO: @@ -9,3 +13,11 @@ class DeploymentExecutionDTO: api: APIDeployment api_key: str + # The global API deployment key that authorized this execution, if any. + # Carrying the resolved key (not just a bool) preserves audit identity — + # "which named key authorized this?" — for downstream logging/incidents. + global_key: "GlobalApiDeploymentKey | None" = None + + @property + def is_global_key(self) -> bool: + return self.global_key is not None diff --git a/backend/api_v2/key_helper.py b/backend/api_v2/key_helper.py index b1c9244ded..cacf40faf4 100644 --- a/backend/api_v2/key_helper.py +++ b/backend/api_v2/key_helper.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import logging 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 @@ -61,6 +64,56 @@ 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 + """ + 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.get(key=api_key, is_active=True) + except (GlobalApiDeploymentKey.DoesNotExist, ValidationError): + # Unknown, inactive, or malformed key. Log the reason for + # observability; the client still gets a generic 401 (we don't + # leak which condition failed). + logger.warning( + "Global API key rejected (unknown/inactive/malformed) for " + "deployment %s (key ...%s).", + api_deployment.id, + str(api_key)[-4:], + ) + raise UnauthorizedKey() from None + + if not global_key.has_access_to_deployment(api_deployment): + logger.warning( + "Global API key '%s' (%s) rejected: no access to deployment %s " + "(out of scope or different organization).", + global_key.name, + global_key.id, + api_deployment.id, + ) + 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 282adc8a4a..68fe379489 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -399,6 +399,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") @@ -409,6 +410,23 @@ def validate_llm_profile_id(self, value): except ProfileManager.DoesNotExist: raise ValidationError("Profile not found") + # 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 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 451a473b07..73295f8376 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -390,6 +390,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..469960791b --- /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.CharField(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=False, + ), + ), + ( + "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.", + editable=False, + 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..05dd73061d --- /dev/null +++ b/backend/global_api_deployment_key/models.py @@ -0,0 +1,64 @@ +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) + # 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( + default=False, + 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: 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: + 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..5af1675ba0 --- /dev/null +++ b/backend/global_api_deployment_key/permissions.py @@ -0,0 +1,8 @@ +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/backend/global_api_deployment_key/serializers.py b/backend/global_api_deployment_key/serializers.py new file mode 100644 index 0000000000..ee788e6efd --- /dev/null +++ b/backend/global_api_deployment_key/serializers.py @@ -0,0 +1,186 @@ +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 + + +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.""" + + 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): + """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 + 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.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() + # 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 + ) + + 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) + + 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( + many=True, queryset=APIDeployment.objects.none(), required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + organization = UserContext.get_organization() + # 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 + ) + + 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 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) + 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..4f02a2d8e4 --- /dev/null +++ b/backend/global_api_deployment_key/views.py @@ -0,0 +1,78 @@ +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): + # 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): + 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 = GlobalApiDeploymentKeyDetailSerializer(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", "modified_at"]) + 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/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 diff --git a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx index eee4f06232..799ce3c614 100644 --- a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx +++ b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx @@ -116,6 +116,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`, + }, ] : []), { @@ -150,6 +155,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"; } @@ -475,6 +483,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/api-key-manager/ApiKeyManager.css b/frontend/src/components/settings/api-key-manager/ApiKeyManager.css new file mode 100644 index 0000000000..fe2081a452 --- /dev/null +++ b/frontend/src/components/settings/api-key-manager/ApiKeyManager.css @@ -0,0 +1,46 @@ +/* Shared styles for the API key management screens (platform + global). */ + +.api-key-manager__content { + padding: 20px; +} + +.api-key-manager__header-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.api-key-manager__key-cell { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + cursor: pointer; +} + +.api-key-manager__key-text { + font-family: monospace; + font-size: 13px; + pointer-events: none; + /* Theme-aware (respects dark mode) instead of a hardcoded rgba. */ + color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.65)); +} + +.api-key-manager__copy-icon { + font-size: 12px; + color: var(--ant-color-text-tertiary, rgba(0, 0, 0, 0.45)); +} + +.api-key-manager__key-cell:hover .api-key-manager__copy-icon { + color: var(--ant-color-text, rgba(0, 0, 0, 0.88)); +} + +.api-key-manager__actions { + display: flex; + gap: 8px; +} + +.api-key-manager__deployment-select { + width: 100%; + margin-top: 8px; +} diff --git a/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx b/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx new file mode 100644 index 0000000000..a0267a9387 --- /dev/null +++ b/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx @@ -0,0 +1,471 @@ +import { + ArrowLeftOutlined, + CopyOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + SyncOutlined, +} from "@ant-design/icons"; +import { + Button, + Form, + Input, + Modal, + Switch, + Table, + Tooltip, + Typography, +} 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 "./ApiKeyManager.css"; + +// Allow-list for user-facing free text (key name/description). Matches the +// backend's utils.input_sanitizer.SAFE_TEXT_PATTERN — alphanumerics, spaces and +// a small set of punctuation (incl. apostrophe); no angle brackets/quotes/& +// that could break out of an HTML attribute when rendered in emails/PDFs/logs. +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."; + +const identity = (v) => v; + +/** + * Shared key-management screen for platform API keys and global API deployment + * keys. Owns the common table + create/edit/rotate/delete/copy flows; callers + * inject the feature-specific bits (extra column, extra form fields, payload + * shaping, edit initial values) via props. + */ +function ApiKeyManager({ + title, + entityLabel, + resourcePath, + extraColumns = [], + renderCreateFields, + renderEditFields, + getEditInitialValues = () => ({}), + transformCreatePayload = identity, + transformEditPayload = identity, +}) { + 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}/${resourcePath}`; + + 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: transformCreatePayload(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)); + }) + .catch(() => { + /* Invalid form: antd renders inline field errors; swallow the reject. */ + }); + }; + + 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: transformEditPayload(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)); + }) + .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")), + ); + }; + + 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, + ...getEditInitialValues(record), + }); + 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) => + dateStr ? new Date(dateStr).toLocaleString() : ""; + + const columns = [ + { + title: "Name", + dataIndex: "name", + key: "name", + width: "13%", + ellipsis: true, + }, + { + title: "Description", + dataIndex: "description", + key: "description", + width: "15%", + ellipsis: true, + }, + { + title: "API Key", + dataIndex: "key", + key: "key", + width: "14%", + render: (_, record) => ( + + + + ), + }, + ...extraColumns, + { + 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: "15%", + ellipsis: true, + }, + { + title: "Created", + dataIndex: "created_at", + key: "created_at", + width: "12%", + render: (text) => formatDate(text), + }, + { + title: "Actions", + key: "actions", + width: "12%", + render: (_, record) => ( +
+ handleRotate(record)} + title="Rotate Key" + content="This will generate a new key and invalidate the current one. Continue?" + okText="Rotate" + > + +
+ ), + }, + ]; + + const nameAndDescriptionRules = { + name: [ + { required: true, message: "Name is required" }, + { max: 128, message: "Name cannot exceed 128 characters" }, + { pattern: SAFE_TEXT_REGEX, message: SAFE_TEXT_MESSAGE }, + ], + description: [ + { required: true, message: "Description is required" }, + { max: 512, message: "Description cannot exceed 512 characters" }, + { pattern: SAFE_TEXT_REGEX, message: SAFE_TEXT_MESSAGE }, + ], + }; + + return ( + +
+
+ + + {title} + +
+
+ +
+
+ +
+ + + + + + + {/* 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.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx new file mode 100644 index 0000000000..b4c9483878 --- /dev/null +++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx @@ -0,0 +1,134 @@ +import { Checkbox, Form, Select, Tag, Tooltip } from "antd"; +import PropTypes from "prop-types"; +import { useCallback, useEffect, useState } from "react"; + +import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; +import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; +import { useAlertStore } from "../../../store/alert-store"; +import { useSessionStore } from "../../../store/session-store"; +import { ApiKeyManager } from "../api-key-manager/ApiKeyManager.jsx"; + +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, +}; + +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) + + ), +}; + +// 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 handleException = useExceptionHandler(); + + const basePath = `/api/v1/unstract/${sessionDetails?.orgId}/global-api-deployment`; + + const fetchDeployments = useCallback(() => { + if (!sessionDetails?.orgId) { + return; + } + axiosPrivate({ method: "GET", url: `${basePath}/deployments/` }) + .then((res) => setDeployments(res?.data || [])) + .catch((err) => + // Surface it: a scoped key requires picking from this list, so a silent + // empty picker would leave the admin unable to create one with no clue why. + setAlertDetails(handleException(err, "Failed to load API deployments")), + ); + }, [basePath, sessionDetails?.orgId]); + + useEffect(() => { + fetchDeployments(); + }, [fetchDeployments]); + + const renderScopeFields = (form) => ( + + ); + + return ( + ({ + 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), + })} + /> + ); +} + +export { GlobalApiDeploymentKeys }; 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={() => ( + +