Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5c5e583
init c hanges
kirtimanmishrazipstack Feb 17, 2026
4ef01eb
init changes
kirtimanmishrazipstack Feb 17, 2026
d2c6d37
init changes
kirtimanmishrazipstack Feb 17, 2026
7e5ef07
changes for co-owner
kirtimanmishrazipstack Feb 17, 2026
656b6ae
FE changes for co-owner
kirtimanmishrazipstack Feb 18, 2026
d579c8a
FE coowner management
kirtimanmishrazipstack Feb 19, 2026
795f0a8
FE coowner management
kirtimanmishrazipstack Feb 19, 2026
91ec5d2
FE coowner management
kirtimanmishrazipstack Feb 20, 2026
91b52dc
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Feb 23, 2026
a6e99b1
Adding FE changes on api deployment co-owner
kirtimanmishrazipstack Feb 23, 2026
cf46432
fix for ETL, Task pipeline
kirtimanmishrazipstack Feb 23, 2026
085f7e1
fix FE
kirtimanmishrazipstack Feb 23, 2026
87c9942
fix FE
kirtimanmishrazipstack Feb 23, 2026
ea9b3eb
Compete implementation of prompt-studio + agentic prompt-studio
kirtimanmishrazipstack Feb 24, 2026
69a2952
UI change
kirtimanmishrazipstack Feb 24, 2026
c60cd4d
UI change
kirtimanmishrazipstack Feb 24, 2026
66ea6df
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Feb 25, 2026
69c1933
worekflow UI
kirtimanmishrazipstack Feb 25, 2026
6e17faa
sonarcloud issue
kirtimanmishrazipstack Feb 25, 2026
4a81ed7
remocing test filew
kirtimanmishrazipstack Feb 25, 2026
50fa507
solving code duplication
kirtimanmishrazipstack Feb 25, 2026
9049c54
fix sonar lint issues
kirtimanmishrazipstack Feb 25, 2026
b2c8ab6
conflict resolve + biome check
kirtimanmishrazipstack Feb 26, 2026
9a9eab6
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Mar 2, 2026
e250f94
conflitcts resolved
kirtimanmishrazipstack Mar 3, 2026
70366e2
ETL+api deployment new UI changes
kirtimanmishrazipstack Mar 3, 2026
b296cd7
conflitcts resolved
kirtimanmishrazipstack Mar 3, 2026
874078e
UN-2022 [FEAT] email notification when co-owner is added/revoked (#1815)
kirtimanmishrazipstack Mar 4, 2026
4bfb980
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Mar 4, 2026
e3797fb
coderabbit review
kirtimanmishrazipstack Mar 4, 2026
5bc5b60
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Mar 5, 2026
2c2786b
PR review
kirtimanmishrazipstack Mar 5, 2026
99b951c
conflicts resolve
kirtimanmishrazipstack Mar 10, 2026
4a91422
conflicts resolve
kirtimanmishrazipstack Mar 10, 2026
8a05862
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Mar 11, 2026
a3a44fc
sonar issues resolve
kirtimanmishrazipstack Mar 11, 2026
4ba9bcf
Merge branch 'UN-2202-Add-co-owners-for-adapters-workflows-API-deploy…
kirtimanmishrazipstack Mar 11, 2026
5095376
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Mar 11, 2026
cc94ec6
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Mar 11, 2026
331be44
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
ritwik-g Mar 12, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.1 on 2026-02-17 08:24

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("adapter_processor_v2", "0003_mark_deprecated_adapters"),
]

operations = [
migrations.AddField(
model_name="adapterinstance",
name="co_owners",
field=models.ManyToManyField(
blank=True,
help_text="Users with full ownership privileges",
related_name="co_owned_adapters",
to=settings.AUTH_USER_MODEL,
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations


def backfill_creator_to_co_owners(apps, schema_editor):
adapter_instance_model = apps.get_model("adapter_processor_v2", "AdapterInstance")
for adapter in adapter_instance_model.objects.filter(created_by__isnull=False):
if not adapter.co_owners.filter(id=adapter.created_by_id).exists():
adapter.co_owners.add(adapter.created_by)


class Migration(migrations.Migration):
dependencies = [
("adapter_processor_v2", "0004_adapterinstance_co_owners"),
]

operations = [
migrations.RunPython(
backfill_creator_to_co_owners,
reverse_code=migrations.RunPython.noop,
),
]
8 changes: 7 additions & 1 deletion backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def for_user(self, user: User) -> QuerySet[Any]:
return (
self.get_queryset()
.filter(
models.Q(created_by=user)
models.Q(co_owners=user)
| models.Q(shared_users=user)
| models.Q(shared_to_org=True)
| models.Q(is_friction_less=True)
Expand Down Expand Up @@ -131,6 +131,12 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel):
# Introduced field to establish M2M relation between users and adapters.
# This will introduce intermediary table which relates both the models.
shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance")
co_owners = models.ManyToManyField(
User,
related_name="co_owned_adapters",
blank=True,
help_text="Users with full ownership privileges",
)
description = models.TextField(blank=True, null=True, default=None)

objects = AdapterInstanceModelManager()
Expand Down
9 changes: 6 additions & 3 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from account_v2.serializer import UserSerializer
from cryptography.fernet import Fernet
from django.conf import settings
from permissions.co_owner_serializers import CoOwnerRepresentationMixin
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer

Expand Down Expand Up @@ -126,7 +127,7 @@ def get_context_window_size(self, obj: AdapterInstance) -> int:
return obj.get_context_window_size()


class AdapterListSerializer(BaseAdapterSerializer):
class AdapterListSerializer(CoOwnerRepresentationMixin, BaseAdapterSerializer):
"""Inherits BaseAdapterSerializer.

Used for listing adapters
Expand Down Expand Up @@ -175,10 +176,10 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
if model:
rep["model"] = model

request = self.context.get("request")
self.add_co_owner_fields(instance, rep, request)
if instance.is_friction_less:
rep["created_by_email"] = "Unstract"
else:
rep["created_by_email"] = instance.created_by.email

return rep

Expand All @@ -190,6 +191,7 @@ class SharedUserListSerializer(BaseAdapterSerializer):
"""

shared_users = UserSerializer(many=True)
co_owners = UserSerializer(many=True, read_only=True)
created_by = UserSerializer()

class Meta(BaseAdapterSerializer.Meta):
Expand All @@ -200,6 +202,7 @@ class Meta(BaseAdapterSerializer.Meta):
"adapter_name",
"adapter_type",
"created_by",
"co_owners",
"shared_users",
"shared_to_org",
) # type: ignore
Expand Down
12 changes: 12 additions & 0 deletions backend/adapter_processor_v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

adapter_users = AdapterInstanceViewSet.as_view({"get": "list_of_shared_users"})
adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"})
adapter_add_owner = AdapterInstanceViewSet.as_view({"post": "add_co_owner"})
Copy link
Contributor

Choose a reason for hiding this comment

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

why does it require to have post/delete operation cant it use existing patch operation? @kirtimanmishrazipstack

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@johnyrahul co_owners is a separate concept from shared_users, and the dedicated endpoints provide better validation (e.g., preventing removal of the last owner)

Copy link
Contributor

Choose a reason for hiding this comment

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

@kirtimanmishrazipstack one clarification: Then why don't we expand the shared users with WRITE/Updated permission instaed of new co-owners entity.?
cc: @hari-kuriakose

adapter_remove_owner = AdapterInstanceViewSet.as_view({"delete": "remove_co_owner"})
urlpatterns = format_suffix_patterns(
[
path("adapter_schema/", adapter_schema, name="get_adapter_schema"),
Expand All @@ -39,5 +41,15 @@
adapter_users,
name="adapter-users",
),
path(
"adapter/<uuid:pk>/owners/",
adapter_add_owner,
name="adapter-add-owner",
),
path(
"adapter/<uuid:pk>/owners/<int:user_id>/",
adapter_remove_owner,
name="adapter-remove-owner",
),
]
)
26 changes: 24 additions & 2 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.db.models import ProtectedError, QuerySet
from django.http import HttpRequest
from django.http.response import HttpResponse
from permissions.co_owner_views import CoOwnerManagementMixin
from permissions.permission import (
IsFrictionLessAdapter,
IsFrictionLessAdapterDelete,
Expand Down Expand Up @@ -135,8 +136,26 @@ def test(self, request: Request) -> Response:
)


class AdapterInstanceViewSet(ModelViewSet):
class AdapterInstanceViewSet(CoOwnerManagementMixin, ModelViewSet):
serializer_class = AdapterInstanceSerializer
notification_resource_name_field = "adapter_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
try:
from plugins.notification.constants import ResourceType
except ImportError:
logger.debug(
"Notification plugin not available, skipping resource type lookup"
)
return None

adapter_type_to_resource = {
"LLM": ResourceType.LLM.value,
"EMBEDDING": ResourceType.EMBEDDING.value,
"VECTOR_DB": ResourceType.VECTOR_DB.value,
"X2TEXT": ResourceType.X2TEXT.value,
}
return adapter_type_to_resource.get(resource.adapter_type)

def get_permissions(self) -> list[Any]:
if self.action in ["update", "retrieve"]:
Expand All @@ -148,6 +167,9 @@ def get_permissions(self) -> list[Any]:
elif self.action in ["list_of_shared_users", "adapter_info"]:
return [IsOwnerOrSharedUserOrSharedToOrg()]

elif self.action in ["add_co_owner", "remove_co_owner"]:
return [IsOwner()]

# Hack for friction-less onboarding
# User cant view/update metadata but can delete/share etc
return [IsOwner()]
Expand All @@ -162,7 +184,7 @@ def get_queryset(self) -> QuerySet | None:
)
else:
queryset = AdapterInstance.objects.for_user(self.request.user)
return queryset
return queryset.prefetch_related("co_owners")

def get_serializer_class(
self,
Expand Down
25 changes: 19 additions & 6 deletions backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from configuration.models import Configuration
from django.db.models import F, OuterRef, QuerySet, Subquery
from django.http import HttpResponse
from permissions.co_owner_views import CoOwnerManagementMixin
from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg
from plugins import get_plugin
from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry
Expand All @@ -17,7 +18,6 @@
from tool_instance_v2.models import ToolInstance
from utils.enums import CeleryTaskState
from utils.hubspot_notify import notify_hubspot_event
from utils.pagination import CustomPagination
from workflow_manager.workflow_v2.dto import ExecutionResponse
from workflow_manager.workflow_v2.models.execution import WorkflowExecution

Expand Down Expand Up @@ -245,11 +245,22 @@ def get(
)


class APIDeploymentViewSet(viewsets.ModelViewSet):
pagination_class = CustomPagination
class APIDeploymentViewSet(CoOwnerManagementMixin, viewsets.ModelViewSet):
notification_resource_name_field = "display_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
from plugins.notification.constants import ResourceType
Copy link
Contributor

Choose a reason for hiding this comment

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

@kirtimanmishrazipstack is this plugin part of OSS?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@johnyrahul The notification plugin is part of OSS — it's in backend/plugins/notification/ and is already imported across multiple existing views in the OSS codebase


return ResourceType.API_DEPLOYMENT.value # type: ignore
Comment on lines +251 to +254
Copy link
Contributor

Choose a reason for hiding this comment

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

Unguarded import will crash on OSS deployments

get_notification_resource_type calls from plugins.notification.constants import ResourceType without a try/except, so on OSS deployments where the notification plugin is not installed this will raise an ImportError whenever add_co_owner or remove_co_owner is triggered. Compare this to AdapterInstanceViewSet.get_notification_resource_type in adapter_processor_v2/views.py which correctly wraps the import in a try/except and returns None:

Suggested change
def get_notification_resource_type(self, resource: Any) -> str | None:
from plugins.notification.constants import ResourceType
return ResourceType.API_DEPLOYMENT.value # type: ignore
def get_notification_resource_type(self, resource: Any) -> str | None:
try:
from plugins.notification.constants import ResourceType
return ResourceType.API_DEPLOYMENT.value # type: ignore
except ImportError:
logger.debug(
"Notification plugin not available, skipping resource type lookup"
)
return None

The same unguarded import pattern is also present in:

  • connector_v2/views.py (ConnectorInstanceViewSet.get_notification_resource_type, line ~41)
  • pipeline_v2/views.py (PipelineViewSet.get_notification_resource_type, line ~40)
  • workflow_manager/workflow_v2/views.py (WorkflowViewSet.get_notification_resource_type, line ~77)
  • prompt_studio/prompt_studio_core_v2/views.py (PromptStudioCoreView.get_notification_resource_type, line ~96)
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/api_v2/api_deployment_views.py
Line: 251-254

Comment:
**Unguarded import will crash on OSS deployments**

`get_notification_resource_type` calls `from plugins.notification.constants import ResourceType` without a try/except, so on OSS deployments where the notification plugin is not installed this will raise an `ImportError` whenever `add_co_owner` or `remove_co_owner` is triggered. Compare this to `AdapterInstanceViewSet.get_notification_resource_type` in `adapter_processor_v2/views.py` which correctly wraps the import in a try/except and returns `None`:

```suggestion
    def get_notification_resource_type(self, resource: Any) -> str | None:
        try:
            from plugins.notification.constants import ResourceType

            return ResourceType.API_DEPLOYMENT.value  # type: ignore
        except ImportError:
            logger.debug(
                "Notification plugin not available, skipping resource type lookup"
            )
            return None
```

The same unguarded import pattern is also present in:
- `connector_v2/views.py` (`ConnectorInstanceViewSet.get_notification_resource_type`, line ~41)
- `pipeline_v2/views.py` (`PipelineViewSet.get_notification_resource_type`, line ~40)
- `workflow_manager/workflow_v2/views.py` (`WorkflowViewSet.get_notification_resource_type`, line ~77)
- `prompt_studio/prompt_studio_core_v2/views.py` (`PromptStudioCoreView.get_notification_resource_type`, line ~96)

How can I resolve this? If you propose a fix, please make it concise.


def get_permissions(self) -> list[Any]:
if self.action in ["destroy", "partial_update", "update"]:
if self.action in [
"destroy",
"partial_update",
"update",
"add_co_owner",
"remove_co_owner",
]:
return [IsOwner()]
return [IsOwnerOrSharedUserOrSharedToOrg()]

Expand Down Expand Up @@ -277,7 +288,7 @@ def get_queryset(self) -> QuerySet | None:
if search:
queryset = queryset.filter(display_name__icontains=search)

return queryset
return queryset.prefetch_related("co_owners")
Copy link
Contributor

Choose a reason for hiding this comment

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

@kirtimanmishrazipstack what exactly is this prefetch_related?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@johnyrahul The prefetch_related("co_owners") is a Django optimization — it pre-fetches the co_owners M2M relation in a single query instead of hitting the DB for each object when co-owners are accessed. This is correct and a standard Django best practice to avoid N+1 queries. Link here


def get_serializer_class(self) -> serializers.Serializer:
if self.action in ["list"]:
Expand Down Expand Up @@ -345,7 +356,9 @@ def by_prompt_studio_tool(self, request: Request) -> Response:
workflow_id__in=workflow_ids, created_by=request.user
)

serializer = APIDeploymentListSerializer(deployments, many=True)
serializer = APIDeploymentListSerializer(
deployments, many=True, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_200_OK)

except PromptStudioRegistry.DoesNotExist:
Expand Down
24 changes: 24 additions & 0 deletions backend/api_v2/migrations/0004_apideployment_co_owners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.1 on 2026-02-17 08:24

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("api_v2", "0003_add_organization_rate_limit"),
]

operations = [
migrations.AddField(
model_name="apideployment",
name="co_owners",
field=models.ManyToManyField(
blank=True,
help_text="Users with full ownership privileges",
related_name="co_owned_api_deployments",
to=settings.AUTH_USER_MODEL,
),
),
]
21 changes: 21 additions & 0 deletions backend/api_v2/migrations/0005_backfill_creator_to_co_owners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations


def backfill_creator_to_co_owners(apps, schema_editor):
api_deployment_model = apps.get_model("api_v2", "APIDeployment")
for deployment in api_deployment_model.objects.filter(created_by__isnull=False):
if not deployment.co_owners.filter(id=deployment.created_by_id).exists():
deployment.co_owners.add(deployment.created_by)


class Migration(migrations.Migration):
dependencies = [
("api_v2", "0004_apideployment_co_owners"),
]

operations = [
migrations.RunPython(
backfill_creator_to_co_owners,
reverse_code=migrations.RunPython.noop,
),
]
10 changes: 8 additions & 2 deletions backend/api_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@
class APIDeploymentModelManager(DefaultOrganizationManagerMixin, models.Manager):
def for_user(self, user):
"""Filter API deployments that the user can access:
- API deployments created by the user
- API deployments co-owned by the user
- API deployments shared with the user
- API deployments shared with the entire organization
"""
from django.db.models import Q

return self.filter(
Q(created_by=user) # Owned by user
Q(co_owners=user) # Co-owned by user
| Q(shared_users=user) # Shared with user
| Q(shared_to_org=True) # Shared to entire organization
).distinct()
Expand Down Expand Up @@ -96,6 +96,12 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel):
shared_users = models.ManyToManyField(
User, related_name="shared_api_deployments", blank=True
)
co_owners = models.ManyToManyField(
User,
related_name="co_owned_api_deployments",
blank=True,
help_text="Users with full ownership privileges",
)
shared_to_org = models.BooleanField(
default=False,
db_comment="Whether this API deployment is shared with the entire organization",
Expand Down
Loading
Loading