Skip to content
Draft
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
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,105 @@
import logging

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

logger = logging.getLogger(__name__)

OWNER = "owner"


def backfill_creator_as_owner(apps, schema_editor):
"""Add each adapter's creator as an OWNER membership row.

``created_by`` is now audit-only; the creator's access flows through this
OWNER row. Adapters with a null ``created_by`` (SET_NULL orphans /
frictionless) are skipped — they have no owner and stay reachable only via
org-admin / service-account overrides.
"""
AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance")

Check warning on line 20 in backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "AdapterInstance" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AZ8nTqCnsqI8nZa5jXL-&open=AZ8nTqCnsqI8nZa5jXL-&pullRequest=1797
AdapterMember = apps.get_model("adapter_processor_v2", "AdapterMember")

Check warning on line 21 in backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "AdapterMember" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AZ8nTqCnsqI8nZa5jXL_&open=AZ8nTqCnsqI8nZa5jXL_&pullRequest=1797
skipped = 0
for adapter in AdapterInstance.objects.iterator():
if not adapter.created_by_id:
skipped += 1
continue
AdapterMember.objects.get_or_create(
adapter=adapter,
user_id=adapter.created_by_id,
defaults={"role": OWNER},
)
if skipped:
logger.warning(
"Skipped %s adapters with null created_by (no owner backfilled).", skipped
)


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

operations = [
migrations.CreateModel(
name="AdapterMember",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"role",
models.CharField(
choices=[("owner", "Owner"), ("viewer", "Viewer")],
default="viewer",
max_length=16,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"adapter",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to="adapter_processor_v2.adapterinstance",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "adapter_member",
"unique_together": {("user", "adapter")},
},
),
migrations.AddField(
model_name="adapterinstance",
name="members",
field=models.ManyToManyField(
help_text="Users with a role (owner/viewer) on this adapter.",
related_name="adapters_member_of",
through="adapter_processor_v2.AdapterMember",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddIndex(
model_name="adaptermember",
index=models.Index(
fields=["adapter", "role"], name="adapter_member_role_idx"
),
),
migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import logging

from django.db import migrations

logger = logging.getLogger(__name__)

VIEWER = "viewer"


def backfill_shared_users_as_viewers(apps, schema_editor):
"""Migrate ``shared_users`` M2M entries into VIEWER membership rows.

Direct user shares become VIEWER-role memberships (UN-2202 Phase 2). A user
already holding an OWNER row is left as-is — ``unique_together(user,
adapter)`` forbids a second row and ownership already grants access.
"""
AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance")

Check warning on line 17 in backend/adapter_processor_v2/migrations/0006_adapter_absorb_shared_users.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "AdapterInstance" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AZ8nzQcSe5Nmo7d-sHAP&open=AZ8nzQcSe5Nmo7d-sHAP&pullRequest=1797
AdapterMember = apps.get_model("adapter_processor_v2", "AdapterMember")

Check warning on line 18 in backend/adapter_processor_v2/migrations/0006_adapter_absorb_shared_users.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "AdapterMember" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AZ8nzQcSe5Nmo7d-sHAQ&open=AZ8nzQcSe5Nmo7d-sHAQ&pullRequest=1797
migrated = 0
for adapter in AdapterInstance.objects.iterator():
for user_id in adapter.shared_users.values_list("id", flat=True):
_, created = AdapterMember.objects.get_or_create(
adapter=adapter, user_id=user_id, defaults={"role": VIEWER}
)
migrated += int(created)
if migrated:
logger.info("Backfilled %s shared_users into VIEWER memberships.", migrated)


class Migration(migrations.Migration):
dependencies = [
("adapter_processor_v2", "0005_adapter_co_owner_membership"),
]

operations = [
migrations.RunPython(backfill_shared_users_as_viewers, migrations.RunPython.noop),
migrations.RemoveField(
model_name="adapterinstance",
name="shared_users",
),
]
35 changes: 29 additions & 6 deletions backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.conf import settings
from django.db import models
from django.db.models import QuerySet
from permissions.models import HasMembersMixin, ResourceMemberBase
from tenant_account_v2.models import OrganizationMember
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.exceptions import InvalidEncryptionKey
Expand Down Expand Up @@ -49,8 +50,7 @@ def for_user(self, user: User) -> QuerySet[Any]:
return (
self.get_queryset()
.filter(
models.Q(created_by=user)
| models.Q(shared_users=user)
models.Q(members=user)
| models.Q(shared_to_org=True)
| models.Q(is_friction_less=True)
| models.Q(pk__in=group_shared_ids)
Expand All @@ -59,7 +59,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 +141,15 @@ 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 via the AdapterMember through
# model (UN-2202); ``created_by`` is audit-only. VIEWER rows are the
# successor to the former ``shared_users`` M2M.
members = models.ManyToManyField(
User,
through="AdapterMember",
related_name="adapters_member_of",
help_text="Users with a role (owner/viewer) on this adapter.",
)
description = models.TextField(blank=True, null=True, default=None)

# ``shared_groups`` is stored polymorphically in
Expand Down Expand Up @@ -201,6 +207,23 @@ def get_context_window_size(self) -> int:
return 0


class AdapterMember(ResourceMemberBase):
"""Per-user role (owner/viewer) on an ``AdapterInstance``."""

adapter = models.ForeignKey(
AdapterInstance,
on_delete=models.CASCADE,
related_name="memberships",
)

class Meta:
db_table = "adapter_member"
unique_together = [("user", "adapter")]
indexes = [
models.Index(fields=["adapter", "role"], name="adapter_member_role_idx")
]


class UserDefaultAdapter(BaseModel):
organization_member = models.OneToOneField(
OrganizationMember,
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",
),
]
)
22 changes: 21 additions & 1 deletion 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 @@ -137,8 +139,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 Down Expand Up @@ -200,6 +215,11 @@ def create(self, request: Any) -> Response:
)

instance = serializer.save()
# ``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
Loading