Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fb44e74
init changes. User's Group's
kirtimanmishrazipstack May 22, 2026
e7e4074
fix migration M2M. Agenctic Prompt Studio align with other resources
kirtimanmishrazipstack May 23, 2026
e3dd41c
Resource Share Modal UI fix
kirtimanmishrazipstack May 23, 2026
22eefa5
users can't access a resource, if admin has removed that user from a …
kirtimanmishrazipstack May 24, 2026
739e453
Removing idp group import, UI Fix
kirtimanmishrazipstack May 25, 2026
e981612
Removing idp group import
kirtimanmishrazipstack May 25, 2026
93e64db
Conflitcs resolved
kirtimanmishrazipstack May 25, 2026
75c94d1
Merge branch 'main' of github.com:Zipstack/unstract into UN-2977-Mood…
kirtimanmishrazipstack May 25, 2026
2cdd2a6
Sonar fix
kirtimanmishrazipstack May 25, 2026
e552fb1
conflicts resolved
kirtimanmishrazipstack May 25, 2026
863c736
UN-2977 [FIX] address Greptile/CodeRabbit review on group sharing
kirtimanmishrazipstack May 25, 2026
8224223
UN-2977 [FIX] validate remove_member user_id + configurable TopBar pl…
kirtimanmishrazipstack May 26, 2026
44b73c2
UN-2977 [FIX] Restrict group resources endpoint to org admins
kirtimanmishrazipstack May 26, 2026
49fd485
UN-2977 [FIX] Drop stale IdP group-sync leftovers from sample.env
kirtimanmishrazipstack May 26, 2026
2d8d098
UN-2977 [FIX] Harden group-share signals + correct member_count and g…
kirtimanmishrazipstack May 26, 2026
1da7105
UN-2977 [FIX] Restore close-on-success in share modals
kirtimanmishrazipstack May 26, 2026
befd61c
UN-2977 [FIX] Remove dead SharedGroupsSerializerMixin
kirtimanmishrazipstack May 26, 2026
8b980c7
Merge branch 'main' of github.com:Zipstack/unstract into UN-2977-Mood…
kirtimanmishrazipstack May 26, 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
15 changes: 15 additions & 0 deletions backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,19 @@ 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)

return (
self.get_queryset()
.filter(
models.Q(created_by=user)
| models.Q(shared_users=user)
| models.Q(shared_to_org=True)
| models.Q(is_friction_less=True)
| models.Q(pk__in=group_shared_ids)
)
.distinct("id")
)
Expand Down Expand Up @@ -140,6 +146,15 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel):
shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance")
description = models.TextField(blank=True, null=True, default=None)

# ``shared_groups`` is stored polymorphically in
# ``tenant_account_v2.ResourceGroupShare``; the property preserves the
# ergonomic read surface for DRF / existing callers.
@property
def shared_groups(self):
from tenant_account_v2.sharing_helpers import get_resource_share_groups

return get_resource_share_groups(self)

objects = AdapterInstanceModelManager()

class Meta:
Expand Down
15 changes: 15 additions & 0 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.conf import settings
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from tenant_account_v2.sharing_helpers import serialize_group_refs
from utils.input_sanitizer import validate_name_field, validate_no_html_tags

from adapter_processor_v2.adapter_processor import AdapterProcessor
Expand All @@ -25,9 +26,18 @@ class TestAdapterSerializer(serializers.Serializer):


class BaseAdapterSerializer(AuditSerializer):
# ``shared_groups`` is no longer an M2M on AdapterInstance — declare it
# explicitly so ``fields = "__all__"`` continues to expose it. Share
# mutations go through ``POST /adapter/{id}/share/`` (UN-2977 plan §B).
shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True)

class Meta:
model = AdapterInstance
fields = "__all__"
extra_kwargs = {
"shared_users": {"read_only": True},
"shared_to_org": {"read_only": True},
}

def validate(self, data):
data = super().validate(data)
Expand Down Expand Up @@ -205,6 +215,7 @@ class SharedUserListSerializer(BaseAdapterSerializer):
"""

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

class Meta(BaseAdapterSerializer.Meta):
Expand All @@ -217,13 +228,17 @@ class Meta(BaseAdapterSerializer.Meta):
"created_by",
"shared_users",
"shared_to_org",
"shared_groups",
) # type: ignore

def get_shared_users(self, obj):
return UserSerializer(
obj.shared_users.filter(is_service_account=False), many=True
).data

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


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 @@ -25,6 +25,8 @@

adapter_users = AdapterInstanceViewSet.as_view({"get": "list_of_shared_users"})
adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"})
adapter_share = AdapterInstanceViewSet.as_view({"post": "share"})
adapter_effective_members = AdapterInstanceViewSet.as_view({"get": "effective_members"})
urlpatterns = format_suffix_patterns(
[
path("adapter_schema/", adapter_schema, name="get_adapter_schema"),
Expand All @@ -39,5 +41,11 @@
adapter_users,
name="adapter-users",
),
path("adapter/<uuid:pk>/share/", adapter_share, name="adapter-share"),
path(
"adapter/<uuid:pk>/effective-members/",
adapter_effective_members,
name="adapter-effective-members",
),
]
)
202 changes: 116 additions & 86 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
IsOwner,
IsOwnerOrSharedUserOrSharedToOrg,
)
from permissions.resource_share_views import ResourceShareManagementMixin
from plugins import get_plugin
from rest_framework import status
from rest_framework.decorators import action
Expand Down Expand Up @@ -135,21 +136,25 @@ def test(self, request: Request) -> Response:
)


class AdapterInstanceViewSet(ModelViewSet):
class AdapterInstanceViewSet(ResourceShareManagementMixin, ModelViewSet):
serializer_class = AdapterInstanceSerializer

def get_permissions(self) -> list[Any]:
if self.action in ["update", "retrieve"]:
# Frictionless adapter quirk preserved: blocks update + retrieve so
# frictionless adapters stay hidden from non-owners, and lets any
# org member delete them. Non-frictionless adapters fall through to
# the standard owner / shared-user gating.
if self.action in ["update", "partial_update", "retrieve"]:
return [IsFrictionLessAdapter()]

elif self.action == "destroy":
if self.action == "destroy":
return [IsFrictionLessAdapterDelete()]

elif self.action in ["list_of_shared_users", "adapter_info"]:
if self.action in [
"list_of_shared_users",
"adapter_info",
"share",
"effective_members",
]:
return [IsOwnerOrSharedUserOrSharedToOrg()]

# Hack for friction-less onboarding
# User cant view/update metadata but can delete/share etc
return [IsOwner()]

def get_queryset(self) -> QuerySet | None:
Expand Down Expand Up @@ -292,91 +297,116 @@ def destroy(
def partial_update(
self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any]
) -> Response:
# Store current shared users before update (for email notifications)
adapter = self.get_object()
current_shared_users = set(adapter.shared_users.all())
before = self.snapshot_share_axes(adapter)

if AdapterKeys.SHARED_USERS in request.data:
# find the deleted users
shared_users = {
int(user_id) for user_id in request.data.get("shared_users", {})
}
current_users = {user.id for user in adapter.shared_users.all()}
removed_users = current_users.difference(shared_users)
response = super().partial_update(request, *args, **kwargs)
if response.status_code == 200 and notification_plugin:
self._notify_shared_users(adapter, before, request.data, request.user)
return response

# if removed user use this adapter as default
# Remove the same from his default
for user_id in removed_users:
try:
organization_member = OrganizationMemberService.get_user_by_id(
id=user_id
)
user_default_adapter: UserDefaultAdapter = (
UserDefaultAdapter.objects.get(
organization_member=organization_member
)
)
@action(detail=True, methods=["post"], url_path="share")
def share(self, request: Request, pk: str | None = None) -> Response:
"""Apply share state, then clear default-adapter links for any user
who lost access.

adapter_fields = [
"default_llm_adapter",
"default_embedding_adapter",
"default_vector_db_adapter",
"default_x2text_adapter",
]

updated = False
for field in adapter_fields:
if getattr(user_default_adapter, field) == adapter:
setattr(user_default_adapter, field, None)
updated = True

if updated:
user_default_adapter.save()
except UserDefaultAdapter.DoesNotExist:
logger.debug(
"User id : %s doesnt have default adapters configured",
user_id,
)
continue
``shared_users`` is read-only on the serializer, so unsharing happens
only here (not via ``partial_update``). Diff the M2M before/after the
commit so cleanup keys off who actually lost access.
"""
adapter = self.get_object()
before_user_ids = set(adapter.shared_users.values_list("id", flat=True))
response = super().share(request, pk)
if response.status_code == status.HTTP_200_OK:
adapter.refresh_from_db()
after_user_ids = set(adapter.shared_users.values_list("id", flat=True))
self._clear_default_adapter_for_removed_users(
adapter, before_user_ids - after_user_ids
)
return response

# Perform the update
response = super().partial_update(request, *args, **kwargs)
def _notify_shared_users(
self,
adapter: AdapterInstance,
before: dict[str, set[Any]],
request_data: dict[str, Any],
actor: Any,
) -> None:
"""Email users newly added to ``shared_users`` (best-effort)."""
users_diff = self.diff_share_axes(adapter, before, request_data).get(
"shared_users"
)
if not (users_diff and users_diff.added):
return
try:
adapter_type_to_resource = {
"LLM": ResourceType.LLM.value,
"EMBEDDING": ResourceType.EMBEDDING.value,
"VECTOR_DB": ResourceType.VECTOR_DB.value,
"X2TEXT": ResourceType.X2TEXT.value,
}
resource_type = adapter_type_to_resource.get(
adapter.adapter_type, ResourceType.LLM.value
)
service_class = notification_plugin["service_class"]
notification_service = service_class()
notification_service.send_sharing_notification(
resource_type=resource_type,
resource_name=adapter.adapter_name,
resource_id=str(adapter.id),
shared_by=actor,
shared_to=list(users_diff.added),
resource_instance=adapter,
)
except Exception as e:
logger.exception("Failed to send sharing notification: %s", e)

# Send email notifications to newly shared users
if response.status_code == 200 and AdapterKeys.SHARED_USERS in request.data:
try:
adapter.refresh_from_db()
new_shared_users = set(adapter.shared_users.all())
newly_shared_users = new_shared_users - current_shared_users

if newly_shared_users:
# Map adapter type to specific resource type
adapter_type_to_resource = {
"LLM": ResourceType.LLM.value,
"EMBEDDING": ResourceType.EMBEDDING.value,
"VECTOR_DB": ResourceType.VECTOR_DB.value,
"X2TEXT": ResourceType.X2TEXT.value,
}

resource_type = adapter_type_to_resource.get(
adapter.adapter_type, ResourceType.LLM.value
)
def _clear_default_adapter_for_removed_users(
self,
adapter: AdapterInstance,
removed_user_ids: set[int],
) -> None:
"""Null out ``UserDefaultAdapter`` rows pointing at ``adapter`` for
users who just lost access via the ``share`` action.
"""
adapter_fields = (
"default_llm_adapter",
"default_embedding_adapter",
"default_vector_db_adapter",
"default_x2text_adapter",
)

# Get notification service from plugin
service_class = notification_plugin["service_class"]
notification_service = service_class()
notification_service.send_sharing_notification(
resource_type=resource_type,
resource_name=adapter.adapter_name,
resource_id=str(adapter.id),
shared_by=request.user,
shared_to=list(newly_shared_users),
resource_instance=adapter,
for user_id in removed_user_ids:
try:
organization_member = OrganizationMemberService.get_user_by_id(id=user_id)
user_default_adapter = UserDefaultAdapter.objects.get(
organization_member=organization_member
)
except UserDefaultAdapter.DoesNotExist:
logger.debug(
"User id : %s doesnt have default adapters configured",
user_id,
)
continue

updated = False
for field_name in adapter_fields:
if getattr(user_default_adapter, field_name) == adapter:
setattr(user_default_adapter, field_name, None)
updated = True
if updated:
# Best-effort: the share already committed, so log a cleanup
# failure instead of letting it surface as a 500 on a successful
# share or silently disappear.
try:
user_default_adapter.save()
except Exception:
logger.exception(
"Failed clearing default adapter for user_id=%s after "
"share on adapter=%s",
user_id,
adapter.id,
)
except Exception as e:
logger.exception(f"Failed to send sharing notification: {e}")

return response

@action(detail=True, methods=["get"])
def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response:
Expand Down
Loading