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
6 changes: 0 additions & 6 deletions backend/file_management/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,10 @@
"get": "list_ide",
}
)
file_delete = FileManagementViewSet.as_view(
{
"get": "delete",
}
)
urlpatterns = format_suffix_patterns(
[
path("file", file_list, name="file-list"),
path("file/download", file_downlaod, name="download"),
path("file/upload", file_upload, name="upload"),
path("file/delete", file_delete, name="delete"),
]
)
41 changes: 2 additions & 39 deletions backend/file_management/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
from connector_v2.models import ConnectorInstance
from django.http import HttpRequest
from oauth2client.client import HttpAccessTokenRefreshError
from prompt_studio.prompt_studio_document_manager_v2.models import DocumentManager
from rest_framework import serializers, status, viewsets
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
from utils.user_session import UserSessionUtils

from file_management.exceptions import (
ConnectorInstanceNotFound,
Expand All @@ -18,21 +16,19 @@
)
from file_management.file_management_helper import FileManagerHelper
from file_management.serializer import (
FileInfoIdeSerializer,
FileInfoSerializer,
FileListRequestSerializer,
FileUploadSerializer,
)
from unstract.connectors.exceptions import ConnectorError
from unstract.connectors.filesystems.local_storage.local_storage import LocalStorageFS

logger = logging.getLogger(__name__)


class FileManagementViewSet(viewsets.ModelViewSet):
"""FileManagement view.

Handles GET,POST,PUT,PATCH and DELETE
Handles GET, POST, PUT and PATCH
"""

versioning_class = URLPathVersioning
Expand Down Expand Up @@ -99,36 +95,3 @@ def upload(self, request: HttpRequest) -> Response:
logger.info(f"Uploading file: {file_name}" if file_name else "Uploading file")
FileManagerHelper.upload_file(file_system, path, uploaded_file, file_name)
return Response({"message": "Files are uploaded successfully!"})

@action(detail=True, methods=["get"])
def delete(self, request: HttpRequest) -> Response:
serializer = FileInfoIdeSerializer(data=request.GET)
serializer.is_valid(raise_exception=True)
document_id: str = serializer.validated_data.get("document_id")
document: DocumentManager = DocumentManager.objects.get(pk=document_id)
file_name: str = document.document_name
tool_id: str = serializer.validated_data.get("tool_id")
file_path = FileManagerHelper.handle_sub_directory_for_tenants(
UserSessionUtils.get_organization_id(request),
is_create=False,
user_id=request.user.user_id,
tool_id=tool_id,
)
path = file_path
file_system = LocalStorageFS(settings={"path": path})
try:
# Delete the document record
document.delete()

# Delete the file
FileManagerHelper.delete_file(file_system, path, file_name)
return Response(
{"data": "File deleted succesfully."},
status=status.HTTP_200_OK,
)
except Exception as exc:
logger.error(f"Exception thrown from file deletion, error {exc}")
return Response(
{"data": "File deletion failed."},
status=status.HTTP_400_BAD_REQUEST,
)
5 changes: 3 additions & 2 deletions backend/prompt_studio/prompt_profile_manager_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
from django.db import models
from django.db.models import Q
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.base_model import BaseModel
from utils.models.org_aware_manager import OrgAwareManager
from utils.user_context import UserContext

from prompt_studio.prompt_studio_core_v2.exceptions import DefaultProfileError
from prompt_studio.prompt_studio_core_v2.models import CustomTool


class ProfileManagerModelManager(BaseModelManager):
class ProfileManagerModelManager(OrgAwareManager):
def for_user(self, user):
"""Read visibility: profile's own share fields OR parent CustomTool sharing.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ def migrate_tool_to_adapter_based(

# Re-fetch the summarize profile with lock within transaction
try:
summarize_profile = ProfileManager.objects.select_for_update().get(
prompt_studio_tool=tool_instance, is_summarize_llm=True
)
# of=("self",): the org-scoped manager joins through
# AdapterInstance, which would otherwise be locked too.
summarize_profile = ProfileManager.objects.select_for_update(
of=("self",)
).get(prompt_studio_tool=tool_instance, is_summarize_llm=True)
Comment on lines +63 to +67

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.

[Medium] [Lens 3, 10] — The except below this now conflates "row is filtered out" with "row does not exist", and never self-heals

The of=("self",) change is correct, and its comment correctly identifies that the org-scoped manager joins through AdapterInstance. But the handler immediately below was not revisited for the same reason.

ProfileManager.objects is now scoped through vector_store__organization. A summarize profile that exists but falls outside that scope raises ObjectDoesNotExist, which is caught at :68 and reported as:

logger.info(f"No summarize profile found for tool {tool_instance.tool_id}, skipping migration")
return False

"Filtered out" and "does not exist" become the same INFO line and the same silent no-op. The tool keeps summarize_llm_adapter = NULL, so summarization keeps using the deprecated profile path — and because this lazy migration re-runs and re-skips on every invocation, it never self-heals and never escalates.

The outer except Exception at :93-99 is worse: it logs at WARNING and says "Continuing with the deprecated approach for now" — an explicit, undocumented fallback to legacy behaviour on any error, now including the ImproperlyConfigured that OrgAwareManager raises when a pin is wrong.

Suggested fix: distinguish the two cases — check ProfileManager._base_manager.filter(prompt_studio_tool=tool_instance, is_summarize_llm=True).exists() before concluding "none found", and log at ERROR with the tool id and current org context when the row exists but is not visible.

Lens 3 · 10

except ObjectDoesNotExist:
logger.info(
f"No summarize profile found for tool {tool_instance.tool_id}, skipping migration"
Expand Down
32 changes: 25 additions & 7 deletions backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
from api_v2.models import APIDeployment
from celery import signature
from celery.result import AsyncResult
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from file_management.constants import FileInformationKey as FileKey
from file_management.exceptions import FileNotFound
Expand Down Expand Up @@ -445,13 +446,24 @@ def make_profile_default(self, request: HttpRequest, pk: Any = None) -> Response
self.get_object()
) # Assuming you have a get_object method in your viewset

ProfileManager.objects.filter(prompt_studio_tool=prompt_tool).update(
is_default=False
# Resolve the target before clearing anything: the id comes straight
# from the request body, and clearing first would leave the tool with no
# default at all when it does not match. Scoped to the same tool the
# caller already passed authz on, so another tool's id is a 404.
profile_manager = get_object_or_404(
ProfileManager,
pk=request.data["default_profile"],
prompt_studio_tool=prompt_tool,
Comment on lines +453 to +456

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.

[Medium] [Lens 3] — The two malformed-input cases next to the hardened one are still 500s, and the full-object save() can clobber a concurrent write

(a) request.data["default_profile"] raises KeyError when the key is absent, and Django's ValidationError ("badly formed hexadecimal UUID string") when the value is not a UUID. Neither is mapped by drf_standardized_errors, so both surface as 500s. This diff deliberately hardened the adjacent case — a valid id that does not match becomes a 404 via get_object_or_404 — and left the two malformed-input siblings as server errors.

(b) profile_manager is fetched before transaction.atomic() opens and then written with a bare save(), which writes every column from the pre-transaction snapshot. Any concurrent edit to that profile between the fetch and the save is silently reverted. The transaction added here guarantees the two writes are atomic with respect to each other, but not that the second one is a narrow write.

Suggested fix: validate with a small serializer (or request.data.get(...) plus an explicit ValidationError) so missing and malformed ids are 400s; and use profile_manager.save(update_fields=["is_default"]) so only the intended column is written.

Lens 3

)

profile_manager = ProfileManager.objects.get(pk=request.data["default_profile"])
profile_manager.is_default = True
profile_manager.save()
# Both writes in one transaction so a failure between them cannot leave
# the tool with zero defaults or two.
with transaction.atomic():
ProfileManager.objects.filter(prompt_studio_tool=prompt_tool).update(
is_default=False
)
profile_manager.is_default = True
profile_manager.save()

return Response(
status=status.HTTP_200_OK,
Expand Down Expand Up @@ -1186,7 +1198,13 @@ def delete_for_ide(self, request: HttpRequest, pk: uuid) -> Response:
document_id: str = serializer.validated_data.get(ToolStudioPromptKeys.DOCUMENT_ID)
org_id = UserSessionUtils.get_organization_id(request)
user_id = custom_tool.created_by.user_id
document: DocumentManager = DocumentManager.objects.get(pk=document_id)
# Scope to the tool the caller already passed authz on — tighter than
# org scope, and this action never runs filter_queryset().
# get_object_or_404 keeps a non-matching id a 404 rather than an
# unhandled DoesNotExist, which the DRF handler turns into a 500.
document: DocumentManager = get_object_or_404(
DocumentManager, pk=document_id, tool=custom_tool
)
Comment on lines +1201 to +1207

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.

[Medium] [Lens 3, 10] — delete_for_ide reports success while silently leaving Redis indexing flags behind

The scoping tightening on this lookup is correct. The issue is the code immediately after it.

IndexManager.objects.filter(document_manager=document_id) at :1187 is now org-scoped. If it comes back empty because the filter hid the rows rather than because none exist, the for loop body never runs, DocumentIndexingService.remove_document_indexing is never called, and execution proceeds straight to document.delete() and a 200 "File deleted succesfully."

The document row and the file are gone, but the Redis indexing flags persist — so a re-upload of the same file is treated as already-indexed. The user is told the delete succeeded, and nothing distinguishes "this document had no index managers" from "the filter hid them".

The except Exception at :1207-1212 compounds it: connector errors, storage errors, Redis errors and ORM errors all collapse into one 400 {"data": "File deletion failed."} with the detail only in logger.error. Worth noting this PR did correctly delete an identical swallow-everything handler over in file_management/views.py — this one, in the surviving path the same diff edits, was left in place.

Suggested fix: log at WARNING when index_managers is empty, including the resolved org, before proceeding. Split the except Exception into the specific failures (ConnectorError, storage exceptions, IntegrityError) with distinct messages and let unexpected types propagate to the DRF handler.

Lens 3 · 10


try:
# Delete indexed flags in redis
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
from account_v2.models import User
from django.db import models
from utils.models.base_model import BaseModel
from utils.models.org_aware_manager import OrgAwareManager

from prompt_studio.prompt_studio_core_v2.models import CustomTool


class DocumentManager(BaseModel):
"""Model to store the document details."""

# Org scoping lives here because custom @action methods never call
# filter_queryset(), so OrganizationFilterBackend does not run on them.
Comment on lines +14 to +15

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.

[Medium] [Lens 16] — "custom @action methods never call filter_queryset()" is not what is actually happening

This claim is the PR's stated premise and is repeated at seven sites: here, prompt_studio_index_manager_v2/models.py:25-26, prompt_studio_output_manager_v2/models.py:20-21, prompt_studio_v2/models.py:19-20, prompt_studio_output_manager_v2/views.py:125-126, prompt_studio_core_v2/views.py:1201-1204 ("this action never runs filter_queryset()"), and the test_cross_org_isolation.py:3-4 module docstring.

The two actions this PR fixes do run the filter backend. delete_for_ide (prompt_studio_core_v2/views.py:1176) opens with custom_tool = self.get_object(), and make_profile_default (:437-439) does the same. DRF's GenericAPIView.get_object() is queryset = self.filter_queryset(self.get_queryset()) — so OrganizationFilterBackend runs in both.

What actually bypasses the backend is the raw DocumentManager.objects.get(...) / ProfileManager.objects.get(...) call inside the action, not the action itself. The vulnerability is real and the fix is right — the explanation attached to it is not.

Failure mode: a maintainer reads this as "the filter backend does not apply inside any @action" and either adds redundant scoping to actions that are already scoped, or concludes self.get_object() inside an action is unscoped and "fixes" something that is not broken.

Suggested fix: reword once and reference it from the other six sites — something like "the filter backend only scopes querysets routed through filter_queryset(); raw Model.objects lookups inside a view bypass it, so scope at the manager."

Lens 16

objects = OrgAwareManager()

document_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

document_name = models.CharField(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from utils.models.base_model import BaseModel
from utils.models.org_aware_manager import OrgAwareManager
from utils.user_context import UserContext

from prompt_studio.prompt_profile_manager_v2.models import ProfileManager
Expand All @@ -21,6 +22,10 @@
class IndexManager(BaseModel):
"""Model to store the index details."""

# See DocumentManager.objects — custom @action methods bypass the
# OrganizationFilterBackend, so scoping has to be at the manager.
objects = OrgAwareManager()

index_manager_id = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,15 @@ def mark_extraction_status(

# Lock the row (or create an empty one) so concurrent callers
# merge into the same dict rather than clobbering each other.
index_manager, created = (
IndexManager.objects.select_for_update().get_or_create(
document_manager=document,
profile_manager=profile_manager,
defaults={"extraction_status": {}},
)
# of=("self",) because the org-scoped manager joins through
# DocumentManager and CustomTool; without it Postgres locks
# rows in those tables too.
index_manager, created = IndexManager.objects.select_for_update(
of=("self",)
).get_or_create(
document_manager=document,
Comment on lines +113 to +118

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.

[Medium] [Lens 3, 5] — get_or_create on a filtering manager turns a merge into an IntegrityError

Django applies the manager's get_queryset() filter to the get half of get_or_create but not to the create half. Now that IndexManager is org-scoped, any case where the org filter hides an existing row makes get() miss and create() insert — violating unique_document_manager_profile_manager_index (models.py:97-102).

Reachable two ways: an org-context mismatch between the caller and the row, or a CustomTool row with organization IS NULL (the FK is null=True via utils/models/organization_mixin.py:8-16, and save() only backfills it from UserContext, which is None in Celery, management commands and shell).

The PR description acknowledges this shape — "If the get half is filtered out while the row exists, the create half hits the unique constraint" — and calls it "only reachable across organizations". The NULL-org path is a second route to it that does not require two organizations.

Same pattern at prompt_studio_output_manager_v2/output_manager_helper.py:79 against unique_prompt_output_index.

Suggested fix: route these get_or_create calls through _base_manager. The caller already holds an org-verified document and profile_manager, so the manager scoping adds nothing here beyond the failure mode.

Lens 3 · 5

profile_manager=profile_manager,
defaults={"extraction_status": {}},

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.

[High] [Lens 3, 10] — mark_extraction_status swallows the newly-reachable failures, and nothing downstream reads the result

Making this manager org-scoped adds new failure modes to this function, and the existing error handling converts all of them into a silent no-op. Three stages:

  1. DocumentManager.objects.get(pk=document_id) at :98 is now org-filtered, so a mismatch raises DoesNotExist → caught at :152return False. Anything else — including an IntegrityError from this get_or_create racing the unique constraint at models.py:97-102 — is caught by the bare except Exception at :156return False.
  2. prompt_studio_core_v2/internal_views.py:205-213 wraps that as JsonResponse({"success": success}) with HTTP 200.
  3. workers/ide_callback/tasks.py:236-252 only wraps the call in try/except and never inspects the body, so a 200 {"success": false} sails through as a success.

Net effect: extraction_status is never persisted, check_extraction_status (:184-190) returns False forever, and every subsequent Answer Prompt re-runs the full X2Text extraction — a recurring cost and latency regression with no error anywhere in the system.

The bare except Exception at :156 also hides DatabaseError/OperationalError, malformed-JSON TypeError, and the ImproperlyConfigured that OrgAwareManager itself raises when a pin is wrong — all indistinguishable from "document not found".

Suggested fix: return 500 (or 404 for the DoesNotExist case) from the extraction_status endpoint when success is falsy instead of 200; have the worker check response.get("success") and log at ERROR. Narrow :156 to the exception types actually expected. The synchronous caller already does this correctly — prompt_studio_helper.py:2556-2559 and :2571-2575 both check if not success — so the internal path is the weaker of the two for no stated reason.

Lens 3 · 10

)

# Merge in place — update_or_create(defaults=...) would replace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from account_v2.models import User
from django.db import models
from utils.models.base_model import BaseModel
from utils.models.org_aware_manager import OrgAwareManager

from prompt_studio.prompt_profile_manager_v2.models import ProfileManager
from prompt_studio.prompt_studio_core_v2.models import CustomTool
Expand All @@ -16,6 +17,10 @@ class PromptStudioOutputManager(BaseModel):
By default the tools will be added to private tool hub.
"""

# See DocumentManager.objects — custom @action methods bypass the
# OrganizationFilterBackend, so scoping has to be at the manager.
objects = OrgAwareManager()

prompt_output_id = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False
)
Expand Down
22 changes: 13 additions & 9 deletions backend/prompt_studio/prompt_studio_output_manager_v2/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging
from typing import Any

from django.core.exceptions import ObjectDoesNotExist
from django.db.models import QuerySet
from django.http import HttpRequest
from rest_framework import status, viewsets
Expand Down Expand Up @@ -119,17 +118,22 @@ def get_output_for_tool_default(self, request: HttpRequest) -> Response:
tool_id = request.GET.get("tool_id")
document_manager_id = request.GET.get("document_manager")
tool_validation_message = PromptOutputManagerErrorMessage.TOOL_VALIDATION
tool_not_found = PromptOutputManagerErrorMessage.TOOL_NOT_FOUND
if not tool_id:
raise ValidationError(detail=tool_validation_message)

try:
# Fetch ToolStudioPrompt records based on tool_id
tool_studio_prompts = ToolStudioPrompt.objects.filter(
tool_id=tool_id
).order_by("sequence_number")
except ObjectDoesNotExist:
raise ValidationError(detail=tool_not_found)
# Fetch ToolStudioPrompt records based on tool_id.
# Custom actions skip filter_queryset(), so OrganizationFilterBackend
# never runs — scope explicitly to prevent cross-tenant reads.
#
# No exception handling here: filter() does not raise for a missing or
# out-of-org tool, it returns empty. Empty is also the correct result
# for a tool that simply has no prompts yet, which is the normal state
# of a newly created project — so this stays a 200 with an empty body
# rather than a validation error.
tool_studio_prompts = ToolStudioPrompt.objects.filter(
tool_id=tool_id,
tool_id__organization=UserContext.get_organization(),
).order_by("sequence_number")
Comment on lines +128 to +136

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.

[Medium] [Lens 3, 16] — Two claims in this comment do not hold

CodeRabbit already ran the empty-200 discussion on this endpoint and you answered it; CodeRabbit agreed and I am not reopening that. These are the two parts the thread did not cover.

1. "filter() does not raise for a missing or out-of-org tool" is false for the input this endpoint actually receives. tool_id comes straight off the query string at :110 (request.GET.get("tool_id")), and CustomTool.tool_id is a UUIDField primary key. A non-UUID value — ?tool_id=abc — makes UUIDField.to_python raise Django's ValidationError while the query is being built, before any row lookup. drf_standardized_errors.handler.ExceptionHandler.convert_known_exceptions maps only Http404 and Django's PermissionDenied; everything else becomes APIException("Server Error (500)"). So the un-wrapped filter() returns a 500, not the 200-with-empty-body this comment promises.

This is not a regression — the old except ObjectDoesNotExist did not catch it either. The defect is the comment asserting a safety the code does not have, which is what will stop the next maintainer from adding validation. It also contradicts your reply on CodeRabbit's thread ("filter() never raises it") in the one case that matters.

2. There is a third cause of empty that the comment does not list. UserContext.get_organization() returns None on both Organization.DoesNotExist and ProgrammingError (backend/utils/user_context.py:26-34), both swallowed without a log. When it does, this filter compiles to custom_tool.organization_id IS NULL and matches nothing regardless of the tool id. Downstream, output_manager_helper.py:317-340 renders each missing output as "", so the user sees blank extraction results for a project with real persisted outputs — no error, no toast, nothing to correlate in logs.

Endpoint is only reachable under /api/v1/unstract/<org>/, so a null org here is a bug rather than a state worth serving.

Suggested fix: narrow claim 1 to "does not raise for a valid UUID that matches no row", or validate tool_id as a UUID up front and return 400. For claim 2, resolve the organization once and fail loudly when it is None. The identical pattern is at latest_outputs_by_keys (views.py:80-92).

Lens 3 · 16


# Invoke helper method to frame and fetch default response.
result: dict[str, Any] = OutputManagerHelper.fetch_default_output_response(
Expand Down
6 changes: 6 additions & 0 deletions backend/prompt_studio/prompt_studio_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.db import models
from django.utils import timezone
from utils.models.base_model import BaseModel
from utils.models.org_aware_manager import OrgAwareManager

from prompt_studio.prompt_profile_manager_v2.models import ProfileManager
from prompt_studio.prompt_studio_core_v2.models import CustomTool
Expand All @@ -15,6 +16,11 @@ class ToolStudioPrompt(BaseModel):
It has Many to one relation with CustomTool for ToolStudio.
"""

# See DocumentManager.objects — custom @action methods bypass the
# OrganizationFilterBackend, so scoping has to be at the manager.
# tool_id is nullable, so prompts orphaned from their tool are excluded.
objects = OrgAwareManager()

class EnforceType(models.TextChoices):
TEXT = "text", "Response sent as Text"
NUMBER = "number", "Response sent as number"
Expand Down
Empty file.
Loading
Loading