diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 47d0aac34..3c07f623e 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 bfbff58b7..fa7bded85 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 93df13e44..75b8c6277 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 b1c9244de..29ae8aa6d 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 7c9a5a769..ec76b8e3c 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 bb62960ae..9a275d095 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 fd91d3cc2..7c0ead02a 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 000000000..e69de29bb diff --git a/backend/global_api_deployment_key/apps.py b/backend/global_api_deployment_key/apps.py new file mode 100644 index 000000000..1d3924167 --- /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 000000000..f414613bf --- /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=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.", + 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 000000000..e69de29bb diff --git a/backend/global_api_deployment_key/models.py b/backend/global_api_deployment_key/models.py new file mode 100644 index 000000000..e1e581781 --- /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=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): + """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 000000000..0d43cf34a --- /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 000000000..da69a5d54 --- /dev/null +++ b/backend/global_api_deployment_key/serializers.py @@ -0,0 +1,136 @@ +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.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() + 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.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 = [ + "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 000000000..33185bed6 --- /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 000000000..84498e35f --- /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 = 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/pyproject.toml b/backend/pyproject.toml index 536b04332..c6a5a5f59 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/uv.lock b/backend/uv.lock index e2fc56b1b..d0480d15e 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 bb328b1a9..52861002c 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 000000000..6b6c8cc2e --- /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 000000000..945dcda46 --- /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 000000000..9f589265c --- /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 1d5849b10..8c2e1505e 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"; @@ -213,6 +214,10 @@ function useMainAppRoutes() { path="settings/platform-api-keys" element={} /> + } + /> } /> {RequirePlatformAdmin && PlatformAdminPage && (