Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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, [])
Expand Down
36 changes: 27 additions & 9 deletions backend/api_v2/deployment_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
InactiveAPI,
InvalidAPIRequest,
PresignedURLFetchError,
UnauthorizedKey,
)
from api_v2.key_helper import KeyHelper
from api_v2.models import APIDeployment, APIKey
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/api_v2/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ class DeploymentExecutionDTO:

api: APIDeployment
api_key: str
is_global_key: bool = False
47 changes: 47 additions & 0 deletions backend/api_v2/key_helper.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Comment on lines +395 to +397
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Enforce tenant-scoped profile authorization for global keys.
On Lines 395-397, global-key requests skip ownership checks and only require profile existence. That weakens isolation: a caller can potentially use any known llm_profile_id, including from another org. Add an explicit org-scoped authorization check before returning (and keep a generic error on mismatch).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/api_v2/serializers.py` around lines 395 - 397, The code currently
returns value when is_global_key is true without checking the profile's org;
instead, fetch the profile referenced by llm_profile_id and compare its
organization/tenant to the caller's organization (obtainable from the serializer
context request or passed tenant id), and if they don't match raise a generic
validation error; keep the early-return for valid matches but replace the
unconditional return in the is_global_key branch with an org equality check that
raises a serializers.ValidationError (or the existing error type) on mismatch.


# 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 @@ -345,6 +345,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.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",
),
),
Comment on lines +71 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how organization FKs are typically defined in this codebase
rg -n "ForeignKey.*organization" --type py -A3 -B1 | head -80

Repository: Zipstack/unstract

Length of output: 43


🏁 Script executed:

# First, let's find and read the migration file mentioned
find . -path "*global_api_deployment_key/migrations/0001_initial.py" -type f

Repository: Zipstack/unstract

Length of output: 123


🏁 Script executed:

# Search for the GlobalApiDeploymentKey model definition
rg -n "class GlobalApiDeploymentKey" --type py -A 30

Repository: Zipstack/unstract

Length of output: 13214


🏁 Script executed:

# Check for viewsets related to global_api_deployment_key
fd -t f "viewset" | xargs rg -l "GlobalApiDeploymentKey" --type py

Repository: Zipstack/unstract

Length of output: 370


🏁 Script executed:

# Look for other models with nullable organization FKs to understand the pattern
rg -n "organization.*ForeignKey" --type py -B 2 -A 5

Repository: Zipstack/unstract

Length of output: 1884


🏁 Script executed:

cat -n backend/global_api_deployment_key/migrations/0001_initial.py | head -100

Repository: Zipstack/unstract

Length of output: 4113


🏁 Script executed:

# Check the full Meta constraints in the model
cat -n backend/global_api_deployment_key/models.py | tail -20

Repository: Zipstack/unstract

Length of output: 945


🏁 Script executed:

# Check if there are any data migrations or model updates after the initial migration
ls -la backend/global_api_deployment_key/migrations/

Repository: Zipstack/unstract

Length of output: 297


Organization FK nullability follows codebase pattern but allows theoretical duplicate names at DB level.

The organization FK inherits null=True, blank=True from DefaultOrganizationMixin, which is used throughout the codebase. Application controls prevent the theoretical issue: the viewset always filters by organization=UserContext.get_organization() and the create serializer validates name uniqueness per organization. However, the unique constraint on (name, organization) would allow duplicate names when organization is NULL in the database (since NULL != NULL in SQL uniqueness checks).

If organization should always be present at the application level, consider whether the mixin pattern should be reconsidered for this model, or document why nullability is needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/global_api_deployment_key/migrations/0001_initial.py` around lines 71
- 81, The organization FK on this model (the "organization" ForeignKey added in
the migration, inherited via DefaultOrganizationMixin) is nullable which allows
duplicate (name, NULL) rows because SQL treats NULLs as distinct; to fix, make
organization non-nullable at the model/migration level (set null=False,
blank=False and remove default=None on the organization ForeignKey) so the
existing UniqueConstraint on (name, organization) enforces per-organization
uniqueness, and update the model and migration for GlobalAPIDeploymentKey (or
the model defined in this migration) accordingly; if you intentionally need
nullable orgs instead, instead add a partial UniqueConstraint that applies only
when organization IS NOT NULL or document why nullability is required.

],
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.
58 changes: 58 additions & 0 deletions backend/global_api_deployment_key/models.py
Original file line number Diff line number Diff line change
@@ -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",
Comment on lines +16 to +18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Default scope is overly permissive.

Line 16/17 sets allow_all_deployments=True, so newly created keys become org-wide unless callers explicitly override it. That is risky for least-privilege and can cause accidental overexposure.

🔐 Proposed fix
     allow_all_deployments = models.BooleanField(
-        default=True,
+        default=False,
         db_comment="If True, this key can authenticate any API deployment in the org",
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
allow_all_deployments = models.BooleanField(
default=True,
db_comment="If True, this key can authenticate any API deployment in the org",
allow_all_deployments = models.BooleanField(
default=False,
db_comment="If True, this key can authenticate any API deployment in the org",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/global_api_deployment_key/models.py` around lines 16 - 18, The
BooleanField allow_all_deployments is set to default=True which makes new keys
org-wide by default; change the field declaration to use default=False on the
models.BooleanField for allow_all_deployments (preserving db_comment), add the
corresponding schema migration to update the default, and adjust any
factory/tests that relied on the permissive default (or explicitly set
allow_all_deployments=True where intended) so behavior remains explicit.

)
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()
3 changes: 3 additions & 0 deletions backend/global_api_deployment_key/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from platform_api.permissions import IsOrganizationAdmin

__all__ = ["IsOrganizationAdmin"]
Loading
Loading