Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
221f050
Phase-B
kirtimanmishrazipstack Jul 3, 2026
874ff26
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Jul 3, 2026
a296c4e
Single M2M model per resource
kirtimanmishrazipstack Jul 3, 2026
c8e935d
UN-2202 Consolidate co-owner/viewer storage into one ResourceMembersh…
kirtimanmishrazipstack Jul 6, 2026
126e9be
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Jul 6, 2026
810986e
UN-2022 [MISC] Silence false-positive Sonar smells on membership code
kirtimanmishrazipstack Jul 6, 2026
f478dde
UN-2202 [FIX] Eliminate list N+1 for owner/co-owner/creator fields
kirtimanmishrazipstack Jul 6, 2026
0e7e0c8
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Jul 6, 2026
f2fc342
UN-2202 [FIX] Eliminate uuid=varchar 500 on non-admin resource lists
kirtimanmishrazipstack Jul 8, 2026
bda50ba
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Jul 8, 2026
a10f505
UN-2202 [FIX] Let co-owners edit Prompt Studio LLM profiles
kirtimanmishrazipstack Jul 8, 2026
591ac4b
UN-2202 [FIX] Seed OWNER membership when creating a Prompt Studio pro…
kirtimanmishrazipstack Jul 8, 2026
a5d7944
Merge branch 'main' of github.com:Zipstack/unstract into UN-2202-Add-…
kirtimanmishrazipstack Jul 10, 2026
d8ca9c6
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Jul 10, 2026
f8f8f34
UN-2202 [MISC] Add co-owner management integration tests
kirtimanmishrazipstack Jul 10, 2026
a58f5d0
UN-2202 [MISC] Register co-owner management as a critical path
kirtimanmishrazipstack Jul 10, 2026
6d61701
UN-2202 [MISC] Add owner-side co-owner management e2e (optional pendi…
kirtimanmishrazipstack Jul 10, 2026
1e58a76
UN-2202 [MISC] Make e2e-coowners a gating group (CI-verified green)
kirtimanmishrazipstack Jul 10, 2026
dca5390
Merge branch 'main' into UN-2202-Add-co-owners-for-adapters-workflows…
kirtimanmishrazipstack Jul 10, 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
Expand Up @@ -235,8 +235,8 @@ def _check_usage(self, adapter: AdapterInstance) -> int:
default_x2text_adapter=adapter
).count()

# Check if shared with users
usage_count += adapter.shared_users.count()
# Check if shared with direct viewers (VIEWER memberships, UN-2202)
usage_count += len(adapter.viewers())

# Check if shared to organization
if adapter.shared_to_org:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""UN-2202: absorb adapter creator + shared_users into ResourceMembership.

Backfill runs before ``shared_users`` is removed, so it can still read it.
"""

from django.db import migrations
from tenant_account_v2.migrations._membership_backfill import backfill_memberships

APP_LABEL = "adapter_processor_v2"
MODEL_NAME = "AdapterInstance"


def _forward(apps, schema_editor):
backfill_memberships(apps, APP_LABEL, MODEL_NAME)


class Migration(migrations.Migration):
dependencies = [
("adapter_processor_v2", "0004_alter_adapterinstance_organization"),
("tenant_account_v2", "0005_resource_membership"),
]

operations = [
migrations.RunPython(_forward, migrations.RunPython.noop),
migrations.RemoveField(model_name="adapterinstance", name="shared_users"),
]
21 changes: 13 additions & 8 deletions backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
from account_v2.models import User
from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.db.models import QuerySet
from permissions.models import HasMembersMixin
from tenant_account_v2.models import OrganizationMember
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tenant_account_v2.sharing_helpers import (
resources_visible_via_groups,
resources_visible_via_memberships,
)
from utils.exceptions import InvalidEncryptionKey
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
Expand Down Expand Up @@ -41,16 +47,14 @@ def for_user(self, user: User) -> QuerySet[Any]:
if OrganizationMemberService.is_user_organization_admin(user):
return self.get_queryset()

from tenant_account_v2.sharing_helpers import resources_visible_via_groups

user_group_ids = user.group_memberships.values_list("group_id", flat=True)
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)

return (
self.get_queryset()
.filter(
models.Q(created_by=user)
| models.Q(shared_users=user)
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(is_friction_less=True)
| models.Q(pk__in=group_shared_ids)
Expand All @@ -59,7 +63,7 @@ def for_user(self, user: User) -> QuerySet[Any]:
)


class AdapterInstance(DefaultOrganizationMixin, BaseModel):
class AdapterInstance(HasMembersMixin, DefaultOrganizationMixin, BaseModel):
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
Expand Down Expand Up @@ -141,9 +145,10 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel):
db_comment="Metadata about adapter deprecation (reason, date, replacement)",
)

# 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")
# Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in
# the polymorphic ``ResourceMembership`` table. ``created_by`` is
# audit-only; VIEWER rows succeed the former ``shared_users`` M2M.
memberships = GenericRelation("tenant_account_v2.ResourceMembership")
description = models.TextField(blank=True, null=True, default=None)

# ``shared_groups`` is stored polymorphically in
Expand Down
15 changes: 11 additions & 4 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ class Meta:
# auto-validator that 400s on re-save before the view can handle it.
validators = []
extra_kwargs = {
"shared_users": {"read_only": True},
"shared_to_org": {"read_only": True},
}

Expand Down Expand Up @@ -208,6 +207,10 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
else:
rep["created_by_email"] = instance.created_by.email

request = self.context.get("request")
rep["is_owner"] = instance.is_owner(request.user) if request else False
rep["co_owners_count"] = instance.co_owners_count()

return rep


Expand All @@ -219,6 +222,7 @@ class SharedUserListSerializer(BaseAdapterSerializer):

shared_users = serializers.SerializerMethodField()
shared_groups = serializers.SerializerMethodField()
co_owners = serializers.SerializerMethodField()
created_by = UserSerializer()

class Meta(BaseAdapterSerializer.Meta):
Expand All @@ -232,16 +236,19 @@ class Meta(BaseAdapterSerializer.Meta):
"shared_users",
"shared_to_org",
"shared_groups",
"co_owners",
) # type: ignore

def get_shared_users(self, obj):
return UserSerializer(
obj.shared_users.filter(is_service_account=False), many=True
).data
viewers = [u for u in obj.viewers() if not u.is_service_account]
return UserSerializer(viewers, many=True).data

def get_shared_groups(self, obj):
return serialize_group_refs(obj)

def get_co_owners(self, obj):
return UserSerializer(obj.owners(), many=True).data


class UserDefaultAdapterSerializer(ModelSerializer):
class Meta:
Expand Down
8 changes: 8 additions & 0 deletions backend/adapter_processor_v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"})
adapter_share = AdapterInstanceViewSet.as_view({"post": "share"})
adapter_effective_members = AdapterInstanceViewSet.as_view({"get": "effective_members"})
adapter_owners = AdapterInstanceViewSet.as_view({"post": "add_co_owner"})
adapter_owner_detail = AdapterInstanceViewSet.as_view({"delete": "remove_co_owner"})
urlpatterns = format_suffix_patterns(
[
path("adapter_schema/", adapter_schema, name="get_adapter_schema"),
Expand All @@ -47,5 +49,11 @@
adapter_effective_members,
name="adapter-effective-members",
),
path("adapter/<uuid:pk>/owners/", adapter_owners, name="adapter-owners"),
path(
"adapter/<uuid:pk>/owners/<int:user_id>/",
adapter_owner_detail,
name="adapter-owner-detail",
),
]
)
29 changes: 27 additions & 2 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
from django.db.models import ProtectedError, QuerySet
from django.http import HttpRequest
from django.http.response import HttpResponse
from permissions.membership_views import OwnerManagementMixin
from permissions.permission import (
IsFrictionLessAdapter,
IsFrictionLessAdapterDelete,
IsOwner,
IsOwnerOrSharedUserOrSharedToOrg,
)
from permissions.resource_share_views import ResourceShareManagementMixin
from permissions.roles import ResourceRole
from plugins import get_plugin
from rest_framework import status
from rest_framework.decorators import action
Expand Down Expand Up @@ -139,8 +141,21 @@ def test(self, request: Request) -> Response:
)


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

def get_notification_resource_type(self, resource: Any) -> str | None:
if not notification_plugin:
return None
return {
"LLM": ResourceType.LLM.value,
"EMBEDDING": ResourceType.EMBEDDING.value,
"VECTOR_DB": ResourceType.VECTOR_DB.value,
"X2TEXT": ResourceType.X2TEXT.value,
}.get(resource.adapter_type, ResourceType.LLM.value)

def get_permissions(self) -> list[Any]:
# Frictionless adapters: hidden from non-owners (update/retrieve),
Expand All @@ -159,7 +174,12 @@ def get_permissions(self) -> list[Any]:
return [IsOwner()]

def get_queryset(self) -> QuerySet | None:
queryset = AdapterInstance.objects.for_user(self.request.user)
# Avoid per-row queries for owner/co-owner + creator fields in list views
queryset = (
AdapterInstance.objects.for_user(self.request.user)
.select_related("created_by")
.prefetch_related("memberships")
)
if filter_args := FilterHelper.build_filter_args(
self.request,
constant.ADAPTER_TYPE,
Expand Down Expand Up @@ -230,6 +250,11 @@ def create(self, request: Any) -> Response:
# consistent with the org the controlled-mode check above evaluated,
# so the per-org restriction can't be sidestepped via the payload.
instance = serializer.save(organization=UserContext.get_organization())
# ``created_by`` is audit-only; the creator's access flows through
# an OWNER membership row (UN-2202 co-owners).
instance.memberships.get_or_create(
user_id=request.user.id, defaults={"role": ResourceRole.OWNER}
)
organization_member = OrganizationMemberService.get_user_by_id(
request.user.id
)
Expand Down
28 changes: 26 additions & 2 deletions backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

from django.db.models import F, OuterRef, QuerySet, Subquery
from django.http import HttpResponse
from permissions.membership_views import OwnerManagementMixin
from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg
from permissions.resource_share_views import ResourceShareManagementMixin
from permissions.roles import ResourceRole
from plugins import get_plugin
from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry
from rest_framework import serializers, status, views, viewsets
Expand Down Expand Up @@ -237,11 +239,25 @@ def get(
)


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

def get_notification_resource_type(self, resource: Any) -> str | None:
if not notification_plugin:
return None
return ResourceType.API_DEPLOYMENT.value

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 All @@ -253,8 +269,11 @@ def get_queryset(self) -> QuerySet | None:
.values("created_at")[:1]
)

# Avoid per-row queries for owner/co-owner + creator fields in list views
queryset = (
APIDeployment.objects.for_user(self.request.user)
.select_related("created_by")
.prefetch_related("memberships")
.annotate(last_run_time_annotated=Subquery(last_run_subquery))
.order_by(F("last_run_time_annotated").desc(nulls_last=True))
)
Expand Down Expand Up @@ -297,6 +316,11 @@ def create(
serializer: Serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# ``created_by`` is audit-only; the creator's access flows through an
# OWNER membership row (UN-2202 co-owners).
serializer.instance.memberships.get_or_create(
user_id=request.user.id, defaults={"role": ResourceRole.OWNER}
)
api_key = DeploymentHelper.create_api_key(serializer=serializer, request=request)
response_serializer = DeploymentResponseSerializer(
{"api_key": api_key.api_key, **serializer.data}
Expand Down
26 changes: 26 additions & 0 deletions backend/api_v2/migrations/0005_absorb_shared_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""UN-2202: absorb API deployment creator + shared_users into ResourceMembership.

Backfill runs before ``shared_users`` is removed, so it can still read it.
"""

from django.db import migrations
from tenant_account_v2.migrations._membership_backfill import backfill_memberships

APP_LABEL = "api_v2"
MODEL_NAME = "APIDeployment"


def _forward(apps, schema_editor):
backfill_memberships(apps, APP_LABEL, MODEL_NAME)


class Migration(migrations.Migration):
dependencies = [
("api_v2", "0004_alter_apideployment_organization_and_more"),
("tenant_account_v2", "0005_resource_membership"),
]

operations = [
migrations.RunPython(_forward, migrations.RunPython.noop),
migrations.RemoveField(model_name="apideployment", name="shared_users"),
]
22 changes: 14 additions & 8 deletions backend/api_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
from typing import Any

from account_v2.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.db.models import Q
from django.db.models.signals import post_delete
from django.dispatch import receiver
from permissions.models import HasMembersMixin
from pipeline_v2.models import Pipeline
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tenant_account_v2.sharing_helpers import (
resources_visible_via_groups,
resources_visible_via_memberships,
)
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
Expand Down Expand Up @@ -41,19 +47,17 @@ def for_user(self, user):
if OrganizationMemberService.is_user_organization_admin(user):
return self.all()

from tenant_account_v2.sharing_helpers import resources_visible_via_groups

user_group_ids = user.group_memberships.values_list("group_id", flat=True)
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)
return self.filter(
Q(created_by=user) # Owned by user
| Q(shared_users=user) # Shared with user
Q(pk__in=member_ids) # Owner or direct viewer (created_by audit-only)
| Q(shared_to_org=True) # Shared to entire organization
| Q(pk__in=group_shared_ids) # Shared via group membership
).distinct()


class APIDeployment(DefaultOrganizationMixin, BaseModel):
class APIDeployment(HasMembersMixin, DefaultOrganizationMixin, BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
display_name = models.CharField(
max_length=API_NAME_MAX_LENGTH,
Expand Down Expand Up @@ -106,9 +110,6 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel):
editable=False,
)
# Sharing fields
shared_users = models.ManyToManyField(
User, related_name="shared_api_deployments", blank=True
)
shared_to_org = models.BooleanField(
default=False,
db_comment="Whether this API deployment is shared with the entire organization",
Expand All @@ -123,6 +124,11 @@ def shared_groups(self):

return get_resource_share_groups(self)

# Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in
# the polymorphic ``ResourceMembership`` table. ``created_by`` is
# audit-only; VIEWER rows succeed the former ``shared_users`` M2M.
memberships = GenericRelation("tenant_account_v2.ResourceMembership")

# Manager
objects = APIDeploymentModelManager()

Expand Down
Loading
Loading