Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4ee988e
UN-2407 [FEAT] Add global API deployment keys for grouped API deploym…
Deepak-Kesavan Mar 31, 2026
4f761fc
UN-2407 [FIX] Address PR review comments for global API deployment keys
Deepak-Kesavan Apr 1, 2026
dca5581
UN-2407 [FIX] Add admin permission message and hoist DeploymentScopeF…
Deepak-Kesavan Jun 9, 2026
a66cbb5
Merge remote-tracking branch 'origin/main' into UN-2407-sync
Deepak-Kesavan Jul 9, 2026
cb51b3c
UN-2407 [FIX] Address review comments: shared safe-text util + org-sc…
Deepak-Kesavan Jul 9, 2026
4b759af
UN-2407 [FIX] Add migration for editable=False on organization field
Deepak-Kesavan Jul 9, 2026
985d3db
UN-2407 [FIX] Deployment subset assignment + self-review fixes
Deepak-Kesavan Jul 9, 2026
06b0770
UN-2407 [FIX] Address human review: least-privilege default, scope va…
Deepak-Kesavan Jul 9, 2026
68fb2e9
UN-2407 [FIX] Address review: audit identity + per-reason logging + s…
Deepak-Kesavan Jul 9, 2026
c45d5a0
UN-2407 [FIX] Extract shared ApiKeyManager; de-duplicate platform + g…
Deepak-Kesavan Jul 9, 2026
e25728a
UN-2407 [FIX] Merge implicitly concatenated string (SonarCloud S5799)
Deepak-Kesavan Jul 9, 2026
fa93485
UN-2407 [FIX] Global-key auth tests, PATCH scope traps, import-time D…
Deepak-Kesavan Jul 22, 2026
b7bc6f3
Merge remote-tracking branch 'origin/main' into merge-main-into-un2407
Deepak-Kesavan Jul 24, 2026
37b7eda
UN-2407 [FIX] Address PR review: select_related on key list, Sonar te…
Deepak-Kesavan Jul 28, 2026
c5341fe
Merge branch 'main' into UN-2407-unstract-api-need-to-generate-common…
muhammad-ali-e Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,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, [])
Expand Down
52 changes: 43 additions & 9 deletions backend/api_v2/deployment_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +34,7 @@
InactiveAPI,
InvalidAPIRequest,
PresignedURLFetchError,
UnauthorizedKey,
)
from api_v2.key_helper import KeyHelper
from api_v2.models import APIDeployment, APIKey
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 23 additions & 4 deletions backend/api_v2/dto.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from api_v2.models import APIDeployment

if TYPE_CHECKING:
from global_api_deployment_key.models import GlobalApiDeploymentKey

@dataclass

@dataclass(frozen=True)
class DeploymentExecutionDTO:
"""DTO for deployment execution viewset."""
"""DTO for deployment execution viewset.

Frozen: built once per request in ``DeploymentHelper.validate_api_key`` and
only read downstream.
"""

api: APIDeployment
api_key: str
# repr=False: this is the live bearer credential. Without it the generated
# __repr__ writes the raw key into any ``logger.debug("...%s", dto)`` or
# exception repr that touches this object.
api_key: str = field(repr=False)
# 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
53 changes: 53 additions & 0 deletions backend/api_v2/key_helper.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,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")
Expand All @@ -411,6 +412,29 @@ 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:
Comment thread
Deepak-Kesavan marked this conversation as resolved.
# A profile's org is only derivable through its prompt studio tool.
# That FK is nullable, and an unattached profile therefore has no
# org to compare against — ``None`` never equals a real org id, so
# such a profile is rejected. That is deliberate: with no way to
# attribute the profile to an organization, the org-scoped key must
# fail closed rather than accept it.
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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

# Get the specific API key being used
try:
active_api_key = api.api_keys.get(api_key=api_key, is_active=True)
Expand Down
1 change: 1 addition & 0 deletions backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ def filter(self, record):
"configuration",
"dashboard_metrics",
"platform_api",
"global_api_deployment_key",
)
TENANT_APPS = []

Expand Down
1 change: 1 addition & 0 deletions backend/backend/urls_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
Empty file.
6 changes: 6 additions & 0 deletions backend/global_api_deployment_key/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class GlobalApiDeploymentKeyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "global_api_deployment_key"
94 changes: 94 additions & 0 deletions backend/global_api_deployment_key/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
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",
),
),
]
Empty file.
Loading
Loading