diff --git a/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py b/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py index 304fc835a3..34a89c9986 100644 --- a/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py +++ b/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py @@ -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: diff --git a/backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py b/backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py new file mode 100644 index 0000000000..5571902b48 --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py @@ -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") + AdapterMember = apps.get_model("adapter_processor_v2", "AdapterMember") + 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), + ] diff --git a/backend/adapter_processor_v2/migrations/0006_adapter_absorb_shared_users.py b/backend/adapter_processor_v2/migrations/0006_adapter_absorb_shared_users.py new file mode 100644 index 0000000000..182d9cb21f --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0006_adapter_absorb_shared_users.py @@ -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") + AdapterMember = apps.get_model("adapter_processor_v2", "AdapterMember") + 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", + ), + ] diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 4d589f0c8f..3724ffc139 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -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 @@ -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) @@ -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, @@ -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 @@ -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, diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 65ece42a16..51ded0d921 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -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}, } @@ -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 @@ -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): @@ -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: diff --git a/backend/adapter_processor_v2/urls.py b/backend/adapter_processor_v2/urls.py index 9217169e6f..d65857aacd 100644 --- a/backend/adapter_processor_v2/urls.py +++ b/backend/adapter_processor_v2/urls.py @@ -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"), @@ -47,5 +49,11 @@ adapter_effective_members, name="adapter-effective-members", ), + path("adapter//owners/", adapter_owners, name="adapter-owners"), + path( + "adapter//owners//", + adapter_owner_detail, + name="adapter-owner-detail", + ), ] ) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 4cfee77d01..5d6675be30 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -6,6 +6,7 @@ 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, @@ -13,6 +14,7 @@ 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 @@ -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), @@ -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 ) diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 5416212896..5796c87211 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -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 @@ -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()] @@ -297,6 +313,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} diff --git a/backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py b/backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py new file mode 100644 index 0000000000..9dbbf13c76 --- /dev/null +++ b/backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py @@ -0,0 +1,104 @@ +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 API deployment's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Deployments with a null ``created_by`` are skipped. + """ + APIDeployment = apps.get_model("api_v2", "APIDeployment") + APIDeploymentMember = apps.get_model("api_v2", "APIDeploymentMember") + skipped = 0 + for dep in APIDeployment.objects.iterator(): + if not dep.created_by_id: + skipped += 1 + continue + APIDeploymentMember.objects.get_or_create( + api_deployment=dep, + user_id=dep.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s API deployments with null created_by (no owner backfilled).", + skipped, + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api_v2", "0004_alter_apideployment_organization_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="APIDeploymentMember", + 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)), + ( + "api_deployment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="api_v2.apideployment", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "api_deployment_member", + "unique_together": {("user", "api_deployment")}, + }, + ), + migrations.AddField( + model_name="apideployment", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this API deployment.", + related_name="api_deployments_member_of", + through="api_v2.APIDeploymentMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="apideploymentmember", + index=models.Index( + fields=["api_deployment", "role"], name="apidep_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/api_v2/migrations/0006_api_deployment_absorb_shared_users.py b/backend/api_v2/migrations/0006_api_deployment_absorb_shared_users.py new file mode 100644 index 0000000000..bea7d18aeb --- /dev/null +++ b/backend/api_v2/migrations/0006_api_deployment_absorb_shared_users.py @@ -0,0 +1,40 @@ +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``). + """ + APIDeployment = apps.get_model("api_v2", "APIDeployment") + APIDeploymentMember = apps.get_model("api_v2", "APIDeploymentMember") + migrated = 0 + for deployment in APIDeployment.objects.iterator(): + for user_id in deployment.shared_users.values_list("id", flat=True): + _, created = APIDeploymentMember.objects.get_or_create( + api_deployment=deployment, 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 = [ + ("api_v2", "0005_api_deployment_co_owner_membership"), + ] + + operations = [ + migrations.RunPython(backfill_shared_users_as_viewers, migrations.RunPython.noop), + migrations.RemoveField( + model_name="apideployment", + name="shared_users", + ), + ] diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index e4d52aeee5..2563f34bd6 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -7,6 +7,7 @@ from django.db.models import Q from django.db.models.signals import post_delete from django.dispatch import receiver +from permissions.models import HasMembersMixin, ResourceMemberBase from pipeline_v2.models import Pipeline from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager @@ -46,14 +47,13 @@ def for_user(self, user): 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.filter( - Q(created_by=user) # Owned by user - | Q(shared_users=user) # Shared with user + Q(members=user) # Owner or direct viewer (created_by is 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, @@ -106,9 +106,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", @@ -123,6 +120,16 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner + direct-viewer access lives here via the APIDeploymentMember + # 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="APIDeploymentMember", + related_name="api_deployments_member_of", + help_text="Users with a role (owner/viewer) on this API deployment.", + ) + # Manager objects = APIDeploymentModelManager() @@ -176,6 +183,23 @@ class Meta: ] +class APIDeploymentMember(ResourceMemberBase): + """Per-user role (owner/viewer) on an ``APIDeployment``.""" + + api_deployment = models.ForeignKey( + APIDeployment, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "api_deployment_member" + unique_together = [("user", "api_deployment")] + indexes = [ + models.Index(fields=["api_deployment", "role"], name="apidep_member_role_idx") + ] + + class OrganizationRateLimit(DefaultOrganizationMixin, BaseModel): """Model to store organization-specific API deployment rate limits.""" diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 282adc8a4a..2a86565be9 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -48,7 +48,6 @@ class Meta: # that 400s on re-save before the mixin can map a friendly message. validators = [] extra_kwargs = { - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -456,6 +455,8 @@ class APIDeploymentListSerializer(ModelSerializer): last_5_run_statuses = SerializerMethodField() run_count = SerializerMethodField() last_run_time = SerializerMethodField() + is_owner = SerializerMethodField() + co_owners_count = SerializerMethodField() class Meta: model = APIDeployment @@ -473,12 +474,21 @@ class Meta: "last_5_run_statuses", "run_count", "last_run_time", + "is_owner", + "co_owners_count", ] def get_created_by_email(self, obj): """Get the email of the creator.""" return obj.created_by.email if obj.created_by else None + def get_is_owner(self, obj) -> bool: + request = self.context.get("request") + return obj.is_owner(request.user) if request else False + + def get_co_owners_count(self, obj) -> int: + return obj.co_owners_count() + def get_run_count(self, instance) -> int: """Get total execution count for this API deployment.""" return WorkflowExecution.objects.filter(pipeline_id=instance.id).count() @@ -534,6 +544,7 @@ class SharedUserListSerializer(ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: @@ -544,14 +555,16 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", ] def get_shared_users(self, obj): - """Return list of shared users with id and email.""" + """Return direct viewers (VIEWER members) with id and email.""" return [ {"id": user.id, "email": user.email} - for user in obj.shared_users.filter(is_service_account=False) + for user in obj.viewers() + if not user.is_service_account ] def get_shared_groups(self, obj): @@ -562,3 +575,7 @@ def get_created_by(self, obj): if obj.created_by: return {"id": obj.created_by.id, "email": obj.created_by.email} return None + + def get_co_owners(self, obj): + """Return co-owners (OWNER members) with id and email.""" + return [{"id": u.id, "email": u.email} for u in obj.owners()] diff --git a/backend/api_v2/urls.py b/backend/api_v2/urls.py index 72c1472275..eafc3c9a93 100644 --- a/backend/api_v2/urls.py +++ b/backend/api_v2/urls.py @@ -35,6 +35,8 @@ ) deployment_share = APIDeploymentViewSet.as_view({"post": "share"}) deployment_effective_members = APIDeploymentViewSet.as_view({"get": "effective_members"}) +deployment_owners = APIDeploymentViewSet.as_view({"post": "add_co_owner"}) +deployment_owner_detail = APIDeploymentViewSet.as_view({"delete": "remove_co_owner"}) execute = DeploymentExecution.as_view() @@ -75,6 +77,16 @@ deployment_effective_members, name="api_deployment_effective_members", ), + path( + "deployment//owners/", + deployment_owners, + name="api_deployment_owners", + ), + path( + "deployment//owners//", + deployment_owner_detail, + name="api_deployment_owner_detail", + ), path( "deployment/by-prompt-studio-tool/", by_prompt_studio_tool, diff --git a/backend/connector_v2/migrations/0007_connector_co_owner_membership.py b/backend/connector_v2/migrations/0007_connector_co_owner_membership.py new file mode 100644 index 0000000000..67c6c2b92e --- /dev/null +++ b/backend/connector_v2/migrations/0007_connector_co_owner_membership.py @@ -0,0 +1,103 @@ +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 connector's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Connectors with a null ``created_by`` are skipped. + """ + ConnectorInstance = apps.get_model("connector_v2", "ConnectorInstance") + ConnectorMember = apps.get_model("connector_v2", "ConnectorMember") + skipped = 0 + for connector in ConnectorInstance.objects.iterator(): + if not connector.created_by_id: + skipped += 1 + continue + ConnectorMember.objects.get_or_create( + connector=connector, + user_id=connector.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s connectors with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("connector_v2", "0006_alter_connectorinstance_organization"), + ] + + operations = [ + migrations.CreateModel( + name="ConnectorMember", + 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)), + ( + "connector", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="connector_v2.connectorinstance", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "connector_member", + "unique_together": {("user", "connector")}, + }, + ), + migrations.AddField( + model_name="connectorinstance", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this connector.", + related_name="connectors_member_of", + through="connector_v2.ConnectorMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="connectormember", + index=models.Index( + fields=["connector", "role"], name="connector_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/connector_v2/migrations/0008_connector_absorb_shared_users.py b/backend/connector_v2/migrations/0008_connector_absorb_shared_users.py new file mode 100644 index 0000000000..7dfffde323 --- /dev/null +++ b/backend/connector_v2/migrations/0008_connector_absorb_shared_users.py @@ -0,0 +1,40 @@ +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``). + """ + ConnectorInstance = apps.get_model("connector_v2", "ConnectorInstance") + ConnectorMember = apps.get_model("connector_v2", "ConnectorMember") + migrated = 0 + for connector in ConnectorInstance.objects.iterator(): + for user_id in connector.shared_users.values_list("id", flat=True): + _, created = ConnectorMember.objects.get_or_create( + connector=connector, 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 = [ + ("connector_v2", "0007_connector_co_owner_membership"), + ] + + operations = [ + migrations.RunPython(backfill_shared_users_as_viewers, migrations.RunPython.noop), + migrations.RemoveField( + model_name="connectorinstance", + name="shared_users", + ), + ] diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 946bab005b..04926f01b0 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -7,6 +7,7 @@ from connector_processor.connector_processor import ConnectorProcessor from connector_processor.constants import ConnectorKeys from django.db import models +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.fields import EncryptedBinaryField from utils.models.base_model import BaseModel, BaseModelManager @@ -42,8 +43,7 @@ def for_user(self, user: User) -> models.QuerySet: 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(pk__in=group_shared_ids) ) @@ -51,7 +51,7 @@ def for_user(self, user: User) -> models.QuerySet: ) -class ConnectorInstance(DefaultOrganizationMixin, BaseModel): +class ConnectorInstance(HasMembersMixin, DefaultOrganizationMixin, BaseModel): class ConnectorType(models.TextChoices): INPUT = "INPUT", "Input" OUTPUT = "OUTPUT", "Output" @@ -105,12 +105,6 @@ class ConnectorMode(models.TextChoices): db_comment="Is the connector shared to entire org", ) - # Introduced field to establish M2M relation between users and connectors. - # This will introduce intermediary table which relates both the models. - shared_users = models.ManyToManyField( - User, related_name="shared_connectors", blank=True - ) - # ``shared_groups`` is stored polymorphically in # ``tenant_account_v2.ResourceGroupShare``; the property preserves the # ergonomic read surface for DRF / existing callers. @@ -120,6 +114,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the ConnectorMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="ConnectorMember", + related_name="connectors_member_of", + help_text="Users with a role (owner/viewer) on this connector.", + ) + objects = ConnectorInstanceModelManager() @staticmethod @@ -170,3 +173,20 @@ class Meta: name="unique_organization_connector", ), ] + + +class ConnectorMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``ConnectorInstance``.""" + + connector = models.ForeignKey( + ConnectorInstance, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "connector_member" + unique_together = [("user", "connector")] + indexes = [ + models.Index(fields=["connector", "role"], name="connector_member_role_idx") + ] diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index 00e3c667df..c1698a5181 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -9,7 +9,13 @@ from connector_processor.constants import ConnectorKeys from connector_processor.exceptions import InvalidConnectorID, OAuthTimeOut from rest_framework import serializers -from rest_framework.serializers import CharField, SerializerMethodField, ValidationError +from rest_framework.serializers import ( + CharField, + ModelSerializer, + SerializerMethodField, + ValidationError, +) +from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field @@ -41,7 +47,6 @@ class Meta: "connector_name": {"required": False}, # connector_mode is derived from the catalog in to_representation. "connector_mode": {"read_only": True}, - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -165,4 +170,47 @@ def to_representation(self, instance: ConnectorInstance) -> dict[str, str]: # Remove sensitive connector auth from the response rep.pop(CIKey.CONNECTOR_AUTH) + 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 + + +class SharedUserListSerializer(ModelSerializer): + """Connector with shared user + group + co-owner details.""" + + shared_users = SerializerMethodField() + shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() + created_by = SerializerMethodField() + + class Meta: + model = ConnectorInstance + fields = [ + "id", + "connector_name", + "shared_users", + "shared_to_org", + "shared_groups", + "co_owners", + "created_by", + ] + + def get_shared_users(self, obj): + return [ + {"id": u.id, "email": u.email} + for u in obj.viewers() + if not u.is_service_account + ] + + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + + def get_co_owners(self, obj): + return [{"id": u.id, "email": u.email} for u in obj.owners()] + + def get_created_by(self, obj): + if obj.created_by: + return {"id": obj.created_by.id, "email": obj.created_by.email} + return None diff --git a/backend/connector_v2/urls.py b/backend/connector_v2/urls.py index 18f31d1a41..cf36bb8bb5 100644 --- a/backend/connector_v2/urls.py +++ b/backend/connector_v2/urls.py @@ -14,6 +14,9 @@ ) connector_share = CIViewSet.as_view({"post": "share"}) connector_effective_members = CIViewSet.as_view({"get": "effective_members"}) +connector_users = CIViewSet.as_view({"get": "list_of_shared_users"}) +connector_add_owner = CIViewSet.as_view({"post": "add_co_owner"}) +connector_remove_owner = CIViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( [ @@ -25,5 +28,16 @@ connector_effective_members, name="connector-effective-members", ), + path("connector/users//", connector_users, name="connector-users"), + path( + "connector//owners/", + connector_add_owner, + name="connector-add-owner", + ), + path( + "connector//owners//", + connector_remove_owner, + name="connector-remove-owner", + ), ] ) diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index f6803b047d..17cc382616 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -8,10 +8,13 @@ from connector_processor.exceptions import OAuthTimeOut from django.db import IntegrityError from django.db.models import ProtectedError, QuerySet +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 rest_framework import status, viewsets +from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -24,7 +27,7 @@ from .exceptions import DeleteConnectorInUseError from .models import ConnectorInstance -from .serializers import ConnectorInstanceSerializer +from .serializers import ConnectorInstanceSerializer, SharedUserListSerializer notification_plugin = get_plugin("notification") if notification_plugin: @@ -34,12 +37,26 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class ConnectorInstanceViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): versioning_class = URLPathVersioning serializer_class = ConnectorInstanceSerializer + notification_resource_name_field = "connector_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + if not notification_plugin: + return None + return ResourceType.CONNECTOR.value def get_permissions(self) -> list[Any]: - if self.action in ["update", "destroy", "partial_update"]: + if self.action in [ + "update", + "destroy", + "partial_update", + "add_co_owner", + "remove_co_owner", + ]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -182,9 +199,19 @@ def create(self, request: Any) -> Response: f"{CIKey.CONNECTOR_EXISTS}, \ {CIKey.DUPLICATE_API}" ) + # ``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} + ) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + @action(detail=True, methods=["get"]) + def list_of_shared_users(self, request: Request, pk: Any = None) -> Response: + connector = self.get_object() + return Response(SharedUserListSerializer(connector).data) + def perform_destroy(self, instance: ConnectorInstance) -> None: """Override perform_destroy to handle ProtectedError gracefully. diff --git a/backend/permissions/membership_serializers.py b/backend/permissions/membership_serializers.py new file mode 100644 index 0000000000..78afe2543a --- /dev/null +++ b/backend/permissions/membership_serializers.py @@ -0,0 +1,63 @@ +from django.db import transaction +from rest_framework import serializers +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +from permissions.roles import ResourceRole + + +class AddOwnerSerializer(serializers.Serializer): + """Validate and add a user as an OWNER of the resource in context. + + Context requires ``resource``. A user already holding VIEWER access is + promoted to OWNER. + """ + + user_id = serializers.IntegerField() + + def validate_user_id(self, value: int) -> int: + resource = self.context["resource"] + organization = UserContext.get_organization() + if not OrganizationMember.objects.filter( + user__id=value, organization=organization + ).exists(): + raise serializers.ValidationError("User not found in your organization.") + if resource.memberships.filter(user_id=value, role=ResourceRole.OWNER).exists(): + raise serializers.ValidationError("User is already an owner.") + return value + + def save(self, **kwargs) -> None: + resource = self.context["resource"] + # Reverse manager auto-scopes to this resource; promote a viewer or + # create the owner row. + resource.memberships.update_or_create( + user_id=self.validated_data["user_id"], + defaults={"role": ResourceRole.OWNER}, + ) + + +class RemoveOwnerSerializer(serializers.Serializer): + """Validate and remove an OWNER, guarding the last-owner invariant. + + Context requires ``resource`` and ``user_to_remove``. + """ + + def validate(self, attrs: dict) -> dict: + resource = self.context["resource"] + user = self.context["user_to_remove"] + if not resource.memberships.filter(user=user, role=ResourceRole.OWNER).exists(): + raise serializers.ValidationError("User is not an owner of this resource.") + return attrs + + def save(self, **kwargs) -> None: + resource = self.context["resource"] + user = self.context["user_to_remove"] + with transaction.atomic(): + # Lock the resource so a concurrent removal can't drop the last two + # owners at once. + locked = type(resource).objects.select_for_update().get(pk=resource.pk) + if locked.memberships.filter(role=ResourceRole.OWNER).count() <= 1: + raise serializers.ValidationError( + "Cannot remove the last owner. Add another owner first." + ) + locked.memberships.filter(user=user, role=ResourceRole.OWNER).delete() diff --git a/backend/permissions/membership_views.py b/backend/permissions/membership_views.py new file mode 100644 index 0000000000..c14306b091 --- /dev/null +++ b/backend/permissions/membership_views.py @@ -0,0 +1,118 @@ +import logging +from typing import Any + +from account_v2.models import User +from django.shortcuts import get_object_or_404 +from plugins import get_plugin +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from permissions.membership_serializers import AddOwnerSerializer, RemoveOwnerSerializer +from permissions.roles import ResourceRole + +logger = logging.getLogger(__name__) + +notification_plugin = get_plugin("notification") + + +class OwnerManagementMixin: + """Owner (co-owner) management actions for a resource ViewSet. + + Owners are ``OWNER`` rows in the resource's ``memberships`` through model. + Notifications reuse the user-sharing path (``SharingNotificationService``); + a ViewSet opts in by setting ``notification_resource_name_field`` and + overriding ``get_notification_resource_type``. + """ + + notification_resource_name_field: str | None = None + + def get_notification_resource_type(self, resource: Any) -> str | None: + return None + + @action(detail=True, methods=["post"], url_path="owners") + def add_co_owner(self, request: Request, pk: str | None = None) -> Response: + resource = self.get_object() + serializer = AddOwnerSerializer( + data=request.data, context={"request": request, "resource": resource} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + added_user = User.objects.get(id=serializer.validated_data["user_id"]) + self._notify_owner_added(resource, added_user, request.user) + return Response( + {"id": str(resource.pk), "co_owners": self._owner_refs(resource)}, + status=status.HTTP_200_OK, + ) + + @action(detail=True, methods=["delete"], url_path=r"owners/(?P[^/.]+)") + def remove_co_owner( + self, request: Request, pk: str | None = None, user_id: str | None = None + ) -> Response: + resource = self.get_object() + user_to_remove = get_object_or_404(User, id=user_id) + serializer = RemoveOwnerSerializer( + data={}, + context={ + "request": request, + "resource": resource, + "user_to_remove": user_to_remove, + }, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + self._notify_owner_removed(resource, user_to_remove, request.user) + return Response(status=status.HTTP_204_NO_CONTENT) + + @staticmethod + def _owner_refs(resource: Any) -> list[dict[str, Any]]: + return [ + {"id": membership.user_id, "email": membership.user.email} + for membership in resource.memberships.filter( + role=ResourceRole.OWNER + ).select_related("user") + ] + + # --- notifications: reuse the user-sharing service, best-effort --- + + def _notification_context(self, resource: Any) -> tuple[str, str] | None: + if not notification_plugin or not self.notification_resource_name_field: + return None + resource_type = self.get_notification_resource_type(resource) + resource_name = getattr(resource, self.notification_resource_name_field, None) + if resource_type is None or not resource_name: + return None + return resource_type, resource_name + + def _notify_owner_added(self, resource: Any, user: User, actor: Any) -> None: + ctx = self._notification_context(resource) + if ctx is None: + return + resource_type, resource_name = ctx + try: + notification_plugin["service_class"]().send_sharing_notification( + resource_type=resource_type, + resource_name=resource_name, + resource_id=str(resource.pk), + shared_by=actor, + shared_to=[user], + resource_instance=resource, + ) + except Exception as e: + logger.exception("Failed to send co-owner added notification: %s", e) + + def _notify_owner_removed(self, resource: Any, user: User, actor: Any) -> None: + ctx = self._notification_context(resource) + if ctx is None: + return + resource_type, resource_name = ctx + try: + notification_plugin["service_class"]().send_access_removed_notification( + resource_type=resource_type, + resource_name=resource_name, + removed_from=[user], + removed_by=actor, + ) + except Exception as e: + logger.exception("Failed to send co-owner removed notification: %s", e) diff --git a/backend/permissions/models.py b/backend/permissions/models.py new file mode 100644 index 0000000000..1024217ba0 --- /dev/null +++ b/backend/permissions/models.py @@ -0,0 +1,70 @@ +from typing import Any + +from django.conf import settings +from django.db import models + +from permissions.roles import ResourceRole + + +class ResourceMemberBase(models.Model): + """Abstract membership row linking a user to a resource with a role. + + Concrete per-resource subclasses add the resource FK with + ``related_name="memberships"`` plus ``unique_together`` and an index. + Ownership (and, later, viewer access) lives here; the resource's + ``created_by`` becomes audit-only. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="+", # queried from the resource side only + ) + role = models.CharField( + max_length=16, + choices=ResourceRole.choices, + default=ResourceRole.VIEWER, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True + + +class HasMembersMixin: + """Owner accessors for resource models backed by the membership table. + + Requires the resource to expose the ``memberships`` reverse relation from + its per-resource membership through model. + """ + + def owner_memberships(self) -> "models.QuerySet[Any]": + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.OWNER + ).select_related("user") + + def owners(self) -> list[Any]: + return [membership.user for membership in self.owner_memberships()] + + def viewer_memberships(self) -> "models.QuerySet[Any]": + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.VIEWER + ).select_related("user") + + def viewers(self) -> list[Any]: + """Direct viewers (VIEWER role) — the membership successor to the old + ``shared_users`` M2M (UN-2202 Phase 2). + """ + return [membership.user for membership in self.viewer_memberships()] + + def co_owners_count(self) -> int: + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.OWNER + ).count() + + def is_owner(self, user: Any) -> bool: + if user is None: + return False + return self.memberships.filter( # type: ignore[attr-defined] + user=user, role=ResourceRole.OWNER + ).exists() diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 601b17eb3f..02030cd472 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -58,6 +58,39 @@ def _is_organization_admin(request: Request) -> bool: return is_admin +def _is_resource_owner(user: Any, obj: Any) -> bool: + """True if ``user`` owns ``obj``. + + Resources migrated to the membership model expose ``memberships`` — owner + means an OWNER-role row (creator + co-owners). Resources not yet migrated + fall back to ``created_by`` (co-owners rollout, UN-2202). ``created_by`` is + no longer consulted once a resource adopts the membership model. + """ + memberships = getattr(obj, "memberships", None) + if memberships is None: + return obj.created_by == user + from permissions.roles import ResourceRole + + return memberships.filter(user=user, role=ResourceRole.OWNER).exists() + + +def _is_resource_viewer(user: Any, obj: Any) -> bool: + """True if ``user`` has direct viewer access to ``obj``. + + Symmetric to :func:`_is_resource_owner`. Resources migrated to the + membership model expose ``memberships`` — a direct viewer is a VIEWER-role + row (the successor to the old ``shared_users`` M2M, UN-2202 Phase 2). + Resources not yet migrated fall back to the ``shared_users`` M2M so the + shared permission classes keep working for both. + """ + memberships = getattr(obj, "memberships", None) + if memberships is None: + return obj.shared_users.filter(pk=user.pk).exists() + from permissions.roles import ResourceRole + + return memberships.filter(user=user, role=ResourceRole.VIEWER).exists() + + class IsOwner(permissions.BasePermission): """Allow owners and org admins. @@ -70,7 +103,7 @@ class IsOwner(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if _is_service_account(request): return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True if _is_organization_admin(request): return True @@ -87,7 +120,7 @@ def is_workflow_mutator(request: Request, workflow: Any) -> bool: """ if _is_service_account(request): return True - if workflow.created_by == request.user: + if _is_resource_owner(request.user, workflow): return True return _is_organization_admin(request) @@ -120,8 +153,8 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() + _is_resource_owner(request.user, obj) + or _is_resource_viewer(request.user, obj) or has_group_access(request.user, obj) or _is_organization_admin(request) ) @@ -134,8 +167,8 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() + _is_resource_owner(request.user, obj) + or _is_resource_viewer(request.user, obj) or obj.shared_to_org or has_group_access(request.user, obj) or _is_organization_admin(request) @@ -158,7 +191,7 @@ def has_object_permission( return False if _is_service_account(request): return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True return _is_organization_admin(request) @@ -173,6 +206,6 @@ def has_object_permission( ) -> bool: if obj.is_friction_less: return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True return _is_organization_admin(request) diff --git a/backend/permissions/resource_share_views.py b/backend/permissions/resource_share_views.py index b4ed73bf52..1227562531 100644 --- a/backend/permissions/resource_share_views.py +++ b/backend/permissions/resource_share_views.py @@ -147,11 +147,14 @@ def diff_share_axes( def _read_axis(instance: Model, axis: str) -> set[Any]: """Return the current set of related objects on the given axis. - ``shared_groups`` is stored polymorphically in - ``ResourceGroupShare`` rather than as an M2M on the resource model - — route reads through the helper. Other axes still live as M2M - fields on the resource and use ``getattr`` access. + ``shared_users`` is the direct-viewer axis — since UN-2202 Phase 2 it + is backed by VIEWER membership rows, not an M2M (all mixin hosts are + membership-backed resources). ``shared_groups`` is stored + polymorphically in ``ResourceGroupShare`` — route reads through the + helper. """ + if axis == "shared_users": + return set(instance.viewers()) # type: ignore[attr-defined] if axis == "shared_groups": # Lazy import — ``tenant_account_v2`` depends on the permissions # package being importable during Django app loading. diff --git a/backend/permissions/roles.py b/backend/permissions/roles.py new file mode 100644 index 0000000000..df42d2824f --- /dev/null +++ b/backend/permissions/roles.py @@ -0,0 +1,12 @@ +from django.db import models + + +class ResourceRole(models.TextChoices): + """Access role a user holds on a shared resource. + + ``OWNER`` ≈ creator / co-owner (full control); ``VIEWER`` ≈ shared user + (read / use). A future ``EDITOR`` is a one-line addition here. + """ + + OWNER = "owner", "Owner" + VIEWER = "viewer", "Viewer" diff --git a/backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py b/backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py new file mode 100644 index 0000000000..c598f50713 --- /dev/null +++ b/backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py @@ -0,0 +1,103 @@ +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 pipeline's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Pipelines with a null ``created_by`` are skipped. + """ + Pipeline = apps.get_model("pipeline_v2", "Pipeline") + PipelineMember = apps.get_model("pipeline_v2", "PipelineMember") + skipped = 0 + for pipeline in Pipeline.objects.iterator(): + if not pipeline.created_by_id: + skipped += 1 + continue + PipelineMember.objects.get_or_create( + pipeline=pipeline, + user_id=pipeline.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s pipelines with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("pipeline_v2", "0004_alter_pipeline_organization"), + ] + + operations = [ + migrations.CreateModel( + name="PipelineMember", + 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)), + ( + "pipeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="pipeline_v2.pipeline", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "pipeline_member", + "unique_together": {("user", "pipeline")}, + }, + ), + migrations.AddField( + model_name="pipeline", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this pipeline.", + related_name="pipelines_member_of", + through="pipeline_v2.PipelineMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="pipelinemember", + index=models.Index( + fields=["pipeline", "role"], name="pipeline_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/pipeline_v2/migrations/0006_pipeline_absorb_shared_users.py b/backend/pipeline_v2/migrations/0006_pipeline_absorb_shared_users.py new file mode 100644 index 0000000000..51b052d4e2 --- /dev/null +++ b/backend/pipeline_v2/migrations/0006_pipeline_absorb_shared_users.py @@ -0,0 +1,40 @@ +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``). + """ + Pipeline = apps.get_model("pipeline_v2", "Pipeline") + PipelineMember = apps.get_model("pipeline_v2", "PipelineMember") + migrated = 0 + for pipeline in Pipeline.objects.iterator(): + for user_id in pipeline.shared_users.values_list("id", flat=True): + _, created = PipelineMember.objects.get_or_create( + pipeline=pipeline, 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 = [ + ("pipeline_v2", "0005_pipeline_co_owner_membership"), + ] + + operations = [ + migrations.RunPython(backfill_shared_users_as_viewers, migrations.RunPython.noop), + migrations.RemoveField( + model_name="pipeline", + name="shared_users", + ), + ] diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 9cbdcd34f6..5ab17c86bd 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import models from django.db.models import Q +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -41,14 +42,13 @@ def for_user(self, user): group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) return self.filter( - Q(created_by=user) # Owned by user - | Q(shared_users=user) # Shared with user + Q(members=user) # Owner or direct viewer (created_by is audit-only) | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class Pipeline(DefaultOrganizationMixin, BaseModel): +class Pipeline(HasMembersMixin, DefaultOrganizationMixin, BaseModel): """Model to hold data related to Pipelines.""" class PipelineType(models.TextChoices): @@ -119,12 +119,6 @@ class PipelineStatus(models.TextChoices): blank=True, ) # Sharing fields - shared_users = models.ManyToManyField( - User, - related_name="shared_pipelines", - blank=True, - db_comment="Users with whom this pipeline is shared", - ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this pipeline is shared with the entire organization", @@ -139,6 +133,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the PipelineMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="PipelineMember", + related_name="pipelines_member_of", + help_text="Users with a role (owner/viewer) on this pipeline.", + ) + # Manager objects = PipelineModelManager() @@ -179,3 +182,20 @@ class Meta: def is_active(self) -> bool: return bool(self.active) + + +class PipelineMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``Pipeline``.""" + + pipeline = models.ForeignKey( + Pipeline, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "pipeline_member" + unique_together = [("user", "pipeline")] + indexes = [ + models.Index(fields=["pipeline", "role"], name="pipeline_member_role_idx") + ] diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index 1f9aa6fe62..956d9d3bf7 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -30,6 +30,8 @@ class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): created_by_email = SerializerMethodField() last_5_run_statuses = SerializerMethodField() next_run_time = SerializerMethodField() + is_owner = SerializerMethodField() + co_owners_count = SerializerMethodField() # ``shared_groups`` is no longer an M2M on Pipeline — declare it # explicitly so ``fields = "__all__"`` continues to expose it. Share # mutations go through ``POST /pipeline/{id}/share/`` (UN-2977 plan §B). @@ -42,7 +44,6 @@ class Meta: # that 400s on re-save before the view can map a friendly message. validators = [] extra_kwargs = { - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -216,6 +217,13 @@ def get_created_by_email(self, obj): """Get the creator's email address.""" return obj.created_by.email if obj.created_by else None + def get_is_owner(self, obj) -> bool: + request = self.context.get("request") + return obj.is_owner(request.user) if request else False + + def get_co_owners_count(self, obj) -> int: + return obj.co_owners_count() + def get_last_5_run_statuses(self, instance: Pipeline) -> list[dict]: """Fetch the last 5 execution statuses with timestamps for this pipeline.""" return WorkflowExecution.get_last_run_statuses(instance.id, limit=5) diff --git a/backend/pipeline_v2/serializers/sharing.py b/backend/pipeline_v2/serializers/sharing.py index 43c172504b..d0426bd37e 100644 --- a/backend/pipeline_v2/serializers/sharing.py +++ b/backend/pipeline_v2/serializers/sharing.py @@ -12,6 +12,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() created_by_email = SerializerMethodField() @@ -23,19 +24,22 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", "created_by_email", ] def get_shared_users(self, obj): - """Get list of shared users with their details.""" - return UserSerializer( - obj.shared_users.filter(is_service_account=False), many=True - ).data + """Get direct viewers (VIEWER members) with their details.""" + 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 + def get_created_by(self, obj): """Get the creator's username.""" return obj.created_by.username if obj.created_by else None diff --git a/backend/pipeline_v2/urls.py b/backend/pipeline_v2/urls.py index 513edd5389..11a44cdcb2 100644 --- a/backend/pipeline_v2/urls.py +++ b/backend/pipeline_v2/urls.py @@ -40,6 +40,8 @@ pipeline_execute = PipelineViewSet.as_view({"post": "execute"}) pipeline_share = PipelineViewSet.as_view({"post": "share"}) pipeline_effective_members = PipelineViewSet.as_view({"get": "effective_members"}) +pipeline_add_owner = PipelineViewSet.as_view({"post": "add_co_owner"}) +pipeline_remove_owner = PipelineViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( @@ -67,6 +69,16 @@ pipeline_effective_members, name="pipeline-effective-members", ), + path( + "pipeline//owners/", + pipeline_add_owner, + name="pipeline-add-owner", + ), + path( + "pipeline//owners//", + pipeline_remove_owner, + name="pipeline-remove-owner", + ), path( "pipeline/api/postman_collection//", download_postman_collection, diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py index 2cc4aab05d..741e0680af 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -9,8 +9,10 @@ from django.db import IntegrityError from django.db.models import F, QuerySet 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 rest_framework import serializers, status, viewsets from rest_framework.decorators import action @@ -43,7 +45,9 @@ logger = logging.getLogger(__name__) -class PipelineViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class PipelineViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): versioning_class = URLPathVersioning queryset = Pipeline.objects.all() pagination_class = CustomPagination @@ -51,9 +55,24 @@ class PipelineViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): ordering_fields = ["created_at", "last_run_time", "pipeline_name", "run_count"] # Note: Default ordering with nulls_last is applied in get_queryset() # DRF's ordering attribute doesn't support nulls_last natively + notification_resource_name_field = "pipeline_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + # Only ETL/TASK pipelines map to a notification ResourceType. + if not notification_plugin: + return None + if resource.pipeline_type in (ResourceType.ETL.value, ResourceType.TASK.value): + return resource.pipeline_type + return None 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()] @@ -132,6 +151,11 @@ def create(self, request: Request) -> Response: raise DuplicateData( f"{PipelineErrors.PIPELINE_EXISTS}, {PipelineErrors.DUPLICATE_API}" ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + pipeline_instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) return Response(data=serializer.data, status=status.HTTP_201_CREATED) def perform_destroy(self, instance: Pipeline) -> None: diff --git a/backend/platform_api/services.py b/backend/platform_api/services.py index 5eac5127f4..e031cd90d3 100644 --- a/backend/platform_api/services.py +++ b/backend/platform_api/services.py @@ -7,6 +7,7 @@ from account_v2.enums import UserRole from account_v2.models import User from django.apps import apps +from django.core.exceptions import FieldDoesNotExist from django.db import models, transaction from tenant_account_v2.models import OrganizationMember @@ -15,7 +16,7 @@ from platform_api.models import PlatformApiKey -# Business app labels whose models may have created_by / shared_users fields. +# Business app labels whose models may carry created_by / membership rows. # Restricts transfer_ownership to avoid scanning Django built-in and third-party models. _BUSINESS_APP_LABELS = { "adapter_processor_v2", @@ -73,11 +74,18 @@ def _get_user_fk_fields(model: type) -> list[str]: def _get_user_m2m_fields(model: type) -> list[str]: - """Return names of all ManyToManyField fields pointing to User.""" + """Return names of ManyToMany-to-User fields safe to mutate via ``.add()``. + + Membership M2Ms (UN-2202) use a custom through model with a ``role`` + column, which Django forbids mutating with ``.add()``/``.remove()``. Those + are excluded here and transferred by :func:`_transfer_membership_rows`. + """ return [ f.name for f in model._meta.get_fields() - if isinstance(f, models.ManyToManyField) and f.related_model is User + if isinstance(f, models.ManyToManyField) + and f.related_model is User + and f.remote_field.through._meta.auto_created ] @@ -106,17 +114,47 @@ def _transfer_model_ownership(model: type, from_user: User, to_user: User) -> No getattr(instance, field_name).add(to_user) getattr(instance, field_name).remove(from_user) + _transfer_membership_rows(model, from_user, to_user) + + +def _transfer_membership_rows(model: type, from_user: User, to_user: User) -> None: + """Re-point OWNER/VIEWER membership rows from one user to another. + + Membership M2Ms use a custom through model (UN-2202) that ``.add()`` can't + touch. ``unique_together(user, resource)`` means a resource already held by + ``to_user`` can't gain a second row — drop ``from_user``'s row there instead. + """ + try: + members_field = model._meta.get_field("members") + except FieldDoesNotExist: + return + if not ( + isinstance(members_field, models.ManyToManyField) + and members_field.related_model is User + ): + return + through = members_field.remote_field.through + source_fk_id = f"{members_field.m2m_field_name()}_id" # e.g. "adapter_id" + held_resource_ids = set( + through.objects.filter(user=to_user).values_list(source_fk_id, flat=True) + ) + for row in through.objects.filter(user=from_user): + if getattr(row, source_fk_id) in held_resource_ids: + row.delete() # to_user already has a role on this resource + else: + row.user = to_user + row.save(update_fields=["user"]) + def transfer_ownership(from_user: User, to_user: User | None) -> None: """Transfer all resource ownership from one user to another. Replaces from_user with to_user across business models: - created_by / modified_by ForeignKey fields - - shared_users ManyToMany fields - - Also cleans up redundancy: if to_user becomes created_by (owner), - they are removed from shared_users on that record since ownership - already grants full access. + - auto-through ManyToMany fields to User + - OWNER/VIEWER membership rows (custom-through, UN-2202) — re-pointed with + ``unique_together`` dedup so a resource to_user already holds isn't + duplicated. """ if not to_user: return diff --git a/backend/prompt_studio/permission.py b/backend/prompt_studio/permission.py index d3e967e60a..6f24418988 100644 --- a/backend/prompt_studio/permission.py +++ b/backend/prompt_studio/permission.py @@ -1,6 +1,10 @@ from typing import Any -from permissions.permission import has_group_access +from permissions.permission import ( + _is_resource_owner, + _is_resource_viewer, + has_group_access, +) from rest_framework import permissions from rest_framework.request import Request from rest_framework.views import APIView @@ -11,7 +15,7 @@ class PromptAcesssToUser(permissions.BasePermission): """Is the crud to Prompt/Notes allowed to user. A user qualifies when they own the parent ``CustomTool``, are a direct - ``shared_users`` member, reach the project via group sharing + viewer (VIEWER membership, UN-2202), reach the project via group sharing (``ResourceGroupShare`` on the parent tool), or are an org admin (org-wide admin override, UN-3479). """ @@ -20,9 +24,9 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if getattr(request.user, "is_service_account", False): return True tool = obj.tool_id - if tool.created_by == request.user: + if _is_resource_owner(request.user, tool): return True - if tool.shared_users.filter(pk=request.user.pk).exists(): + if _is_resource_viewer(request.user, tool): return True if has_group_access(request.user, tool): return True diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py new file mode 100644 index 0000000000..0f74554dde --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py @@ -0,0 +1,104 @@ +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 custom tool's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Tools with a null ``created_by`` are skipped. + """ + CustomTool = apps.get_model("prompt_studio_core_v2", "CustomTool") + CustomToolMember = apps.get_model("prompt_studio_core_v2", "CustomToolMember") + skipped = 0 + for tool in CustomTool.objects.iterator(): + if not tool.created_by_id: + skipped += 1 + continue + CustomToolMember.objects.get_or_create( + custom_tool=tool, + user_id=tool.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s custom tools with null created_by (no owner backfilled).", + skipped, + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("prompt_studio_core_v2", "0008_alter_customtool_organization"), + ] + + operations = [ + migrations.CreateModel( + name="CustomToolMember", + 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)), + ( + "custom_tool", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="prompt_studio_core_v2.customtool", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "custom_tool_member", + "unique_together": {("user", "custom_tool")}, + }, + ), + migrations.AddField( + model_name="customtool", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this custom tool.", + related_name="custom_tools_member_of", + through="prompt_studio_core_v2.CustomToolMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="customtoolmember", + index=models.Index( + fields=["custom_tool", "role"], name="custom_tool_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0010_custom_tool_absorb_shared_users.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0010_custom_tool_absorb_shared_users.py new file mode 100644 index 0000000000..f180f9245b --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0010_custom_tool_absorb_shared_users.py @@ -0,0 +1,40 @@ +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``). + """ + CustomTool = apps.get_model("prompt_studio_core_v2", "CustomTool") + CustomToolMember = apps.get_model("prompt_studio_core_v2", "CustomToolMember") + migrated = 0 + for tool in CustomTool.objects.iterator(): + for user_id in tool.shared_users.values_list("id", flat=True): + _, created = CustomToolMember.objects.get_or_create( + custom_tool=tool, 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 = [ + ("prompt_studio_core_v2", "0009_custom_tool_co_owner_membership"), + ] + + operations = [ + migrations.RunPython(backfill_shared_users_as_viewers, migrations.RunPython.noop), + migrations.RemoveField( + model_name="customtool", + name="shared_users", + ), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index 7b11320098..1f449ea6c7 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -6,6 +6,7 @@ from adapter_processor_v2.models import AdapterInstance from django.db import models from django.db.models import QuerySet +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.file_storage.constants import FileStorageKeys from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper @@ -38,8 +39,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(pk__in=group_shared_ids) ) @@ -47,7 +47,7 @@ def for_user(self, user: User) -> QuerySet[Any]: ) -class CustomTool(DefaultOrganizationMixin, BaseModel): +class CustomTool(HasMembersMixin, DefaultOrganizationMixin, BaseModel): """Model to store the custom tools designed in the tool studio.""" tool_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -161,10 +161,6 @@ class CustomTool(DefaultOrganizationMixin, BaseModel): db_comment="Custom data for variable replacement in prompts using {{custom_data.key}} syntax", ) - # Introduced field to establish M2M relation between users and custom_tool. - # This will introduce intermediary table which relates both the models. - shared_users = models.ManyToManyField(User, related_name="shared_custom_tools") - # Field to enable organization-level sharing shared_to_org = models.BooleanField( default=False, @@ -189,6 +185,15 @@ def shared_groups(self): db_comment="Timestamp of the last successful export; NULL if never exported since the field was introduced.", ) + # Owner (and, later, viewer) access lives here via the CustomToolMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="CustomToolMember", + related_name="custom_tools_member_of", + help_text="Users with a role (owner/viewer) on this custom tool.", + ) + objects = CustomToolModelManager() def delete(self, organization_id=None, *args, **kwargs): @@ -221,3 +226,22 @@ class Meta: name="unique_tool_name", ), ] + + +class CustomToolMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``CustomTool``.""" + + custom_tool = models.ForeignKey( + CustomTool, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "custom_tool_member" + unique_together = [("user", "custom_tool")] + indexes = [ + models.Index( + fields=["custom_tool", "role"], name="custom_tool_member_role_idx" + ) + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py index 91515db118..47b19c88af 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py +++ b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py @@ -11,7 +11,11 @@ from adapter_processor_v2.models import AdapterInstance, UserDefaultAdapter from django.conf import settings from django.db import transaction -from permissions.permission import has_group_access +from permissions.permission import ( + _is_resource_owner, + _is_resource_viewer, + has_group_access, +) from plugins import get_plugin from rest_framework.exceptions import APIException from rest_framework.request import Request @@ -88,6 +92,20 @@ logger = logging.getLogger(__name__) +def _adapter_accessible_by(adapter: AdapterInstance, user: User) -> bool: + """Whether ``user`` may use ``adapter`` (owner, direct viewer, org, or group). + + ``created_by`` is audit-only since UN-2202 — access is owner/viewer role + based via the membership bridges. + """ + return ( + adapter.shared_to_org + or _is_resource_owner(user, adapter) + or _is_resource_viewer(user, adapter) + or has_group_access(user, adapter) + ) + + class PromptStudioHelper: """Helper class for Custom tool operations.""" @@ -204,38 +222,15 @@ def validate_profile_manager_owner_access( if OrganizationMemberService.is_user_organization_admin(profile_manager_owner): return - is_llm_owned = ( - profile_manager.llm.shared_to_org - or profile_manager.llm.created_by == profile_manager_owner - or profile_manager.llm.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.llm) - ) - is_vector_store_owned = ( - profile_manager.vector_store.shared_to_org - or profile_manager.vector_store.created_by == profile_manager_owner - or profile_manager.vector_store.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.vector_store) - ) - is_embedding_model_owned = ( - profile_manager.embedding_model.shared_to_org - or profile_manager.embedding_model.created_by == profile_manager_owner - or profile_manager.embedding_model.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.embedding_model) - ) - is_x2text_owned = ( - profile_manager.x2text.shared_to_org - or profile_manager.x2text.created_by == profile_manager_owner - or profile_manager.x2text.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.x2text) + owner = profile_manager_owner + is_llm_owned = _adapter_accessible_by(profile_manager.llm, owner) + is_vector_store_owned = _adapter_accessible_by( + profile_manager.vector_store, owner + ) + is_embedding_model_owned = _adapter_accessible_by( + profile_manager.embedding_model, owner ) + is_x2text_owned = _adapter_accessible_by(profile_manager.x2text, owner) if not ( is_llm_owned diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index f465d31488..c37774485a 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -43,6 +43,8 @@ class CustomToolListSerializer(serializers.ModelSerializer): created_by_email = serializers.SerializerMethodField() prompt_count = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() + co_owners_count = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -58,11 +60,20 @@ class Meta: "icon", "created_by_email", "prompt_count", + "is_owner", + "co_owners_count", ] def get_created_by_email(self, instance): return instance.created_by.email if instance.created_by else "" + def get_is_owner(self, instance) -> bool: + request = self.context.get("request") + return instance.is_owner(request.user) if request else False + + def get_co_owners_count(self, instance) -> int: + return instance.co_owners_count() + def get_prompt_count(self, instance): if hasattr(instance, "_prompt_count"): return instance._prompt_count or 0 @@ -76,9 +87,9 @@ def get_prompt_count(self, instance): class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): - # Share mutations go through ``POST /prompt-studio/{id}/share/``; - # both axes are read-only on this serializer (UN-2977 plan §B). - shared_users = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + # Share mutations go through ``POST /prompt-studio/{id}/share/``; the + # groups axis is read-only here (UN-2977 plan §B). Direct viewers live in + # the membership table (UN-2202) and surface via the share-modal serializer. shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: @@ -230,6 +241,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): created_by = UserSerializer() shared_users = serializers.SerializerMethodField() shared_groups = serializers.SerializerMethodField() + co_owners = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -240,16 +252,19 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", ) 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 FileInfoIdeSerializer(serializers.Serializer): document_id = serializers.CharField() diff --git a/backend/prompt_studio/prompt_studio_core_v2/urls.py b/backend/prompt_studio/prompt_studio_core_v2/urls.py index 4a453fa309..290576b299 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/urls.py +++ b/backend/prompt_studio/prompt_studio_core_v2/urls.py @@ -44,6 +44,8 @@ prompt_studio_effective_members = PromptStudioCoreView.as_view( {"get": "effective_members"} ) +prompt_studio_add_owner = PromptStudioCoreView.as_view({"post": "add_co_owner"}) +prompt_studio_remove_owner = PromptStudioCoreView.as_view({"delete": "remove_co_owner"}) prompt_studio_file = PromptStudioCoreView.as_view( @@ -148,6 +150,16 @@ prompt_studio_effective_members, name="prompt-studio-effective-members", ), + path( + "prompt-studio//owners/", + prompt_studio_add_owner, + name="prompt-studio-add-owner", + ), + path( + "prompt-studio//owners//", + prompt_studio_remove_owner, + name="prompt-studio-remove-owner", + ), path( "prompt-studio/file/", prompt_studio_file, diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 001d63a838..0195ccc9e3 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -17,8 +17,10 @@ from django.utils import timezone from file_management.constants import FileInformationKey as FileKey from file_management.exceptions import FileNotFound +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 pipeline_v2.models import Pipeline from plugins import get_plugin from rest_framework import status, viewsets @@ -120,12 +122,22 @@ def _multi_var_lookup_block_response(custom_tool, prompt_ids=None): ) -class PromptStudioCoreView(ResourceShareManagementMixin, viewsets.ModelViewSet): +class PromptStudioCoreView( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): """Viewset to handle all Custom tool related operations.""" versioning_class = URLPathVersioning serializer_class = CustomToolSerializer + notification_resource_name_field = "tool_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + try: + from plugins.notification.constants import ResourceType + except ImportError: + return None + return ResourceType.TEXT_EXTRACTOR.value def get_serializer_class(self): if self.action == "list": @@ -133,7 +145,7 @@ def get_serializer_class(self): return CustomToolSerializer def get_permissions(self) -> list[Any]: - if self.action == "destroy": + if self.action in ["destroy", "add_co_owner", "remove_co_owner"]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -173,6 +185,11 @@ def create(self, request: HttpRequest) -> Response: f"{ToolStudioErrors.TOOL_NAME_EXISTS}, \ {ToolStudioErrors.DUPLICATE_API}" ) + # ``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} + ) PromptStudioHelper.create_default_profile_manager( request.user, serializer.data["tool_id"] ) diff --git a/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py b/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py index 4fee8c10bc..f46298be31 100644 --- a/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py +++ b/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py @@ -206,13 +206,10 @@ def update_or_create_psr_tool( if not shared_with_org: obj.shared_users.clear() obj.shared_users.add(*user_ids) - # add prompt studio users - # for shared_user in custom_tool.shared_users: - obj.shared_users.add( - *custom_tool.shared_users.all().values_list("id", flat=True) - ) - # add prompt studio owner - obj.shared_users.add(custom_tool.created_by) + # Mirror the source tool's access: its direct viewers and its + # owners (creator + co-owners, UN-2202). + obj.shared_users.add(*[u.id for u in custom_tool.viewers()]) + obj.shared_users.add(*[u.id for u in custom_tool.owners()]) else: obj.shared_users.clear() obj.save() diff --git a/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py b/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py index 890baf2fff..dffb3f2fd4 100644 --- a/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py +++ b/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py @@ -14,6 +14,7 @@ from account_v2.models import Organization, User from django.core.exceptions import PermissionDenied from django.test import TestCase +from permissions.roles import ResourceRole from tenant_account_v2.models import OrganizationMember from tool_instance_v2.tool_instance_helper import ToolInstanceHelper from utils.user_context import UserContext @@ -58,6 +59,8 @@ def setUp(self) -> None: created_by=self.owner, organization=self.org, ) + # Creator's access flows through an OWNER membership row (UN-2202). + self.tool.memberships.create(user=self.owner, role=ResourceRole.OWNER) # Owner-only snapshot, as written by an unshared export. self.registry = PromptStudioRegistry.objects.create( name=self.tool.tool_name, @@ -93,12 +96,14 @@ def test_org_share_after_export_makes_tool_visible(self) -> None: self.assertIn(self.registry.prompt_registry_id, self._visible_ids(self.other)) def test_user_share_after_export_makes_tool_visible(self) -> None: - self.tool.shared_users.add(self.other) + self.tool.memberships.create(user=self.other, role=ResourceRole.VIEWER) self.assertIn(self.registry.prompt_registry_id, self._visible_ids(self.other)) def test_unshare_after_share_hides_tool(self) -> None: - self.tool.shared_users.add(self.other) - self.tool.shared_users.remove(self.other) + self.tool.memberships.create(user=self.other, role=ResourceRole.VIEWER) + self.tool.memberships.filter( + user=self.other, role=ResourceRole.VIEWER + ).delete() self.assertNotIn(self.registry.prompt_registry_id, self._visible_ids(self.other)) def test_legacy_row_falls_back_to_own_share_fields(self) -> None: diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py index 6edbd39ae3..8c324ec8df 100644 --- a/backend/tenant_account_v2/sharing_helpers.py +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -218,17 +218,17 @@ def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: """ seen: dict[int, dict[str, Any]] = {} - # Direct shares - direct_users = list( - resource_obj.shared_users.filter(is_service_account=False).values( - "id", "email", "first_name", "last_name" - ) - ) - for u in direct_users: - seen[u["id"]] = { - "user_id": u["id"], - "email": u["email"], - "display_name": _display_name(u), + # Direct shares — VIEWER membership rows (UN-2202 Phase 2 successor to the + # old ``shared_users`` M2M). Owners are excluded here: they hold the + # resource, they are not "shared with" it (parity with prior behavior). + for membership in resource_obj.viewer_memberships(): + user = membership.user + if getattr(user, "is_service_account", False): + continue + seen[user.id] = { + "user_id": user.id, + "email": user.email, + "display_name": _user_display_name(user), "access_via": "direct", "group_id": None, "group_name": None, @@ -285,15 +285,6 @@ def _add_org_members(seen: dict[int, dict[str, Any]], resource_obj: Any) -> None } -def _display_name(user_dict: dict[str, Any]) -> str: - parts = [ - (user_dict.get("first_name") or "").strip(), - (user_dict.get("last_name") or "").strip(), - ] - full = " ".join(p for p in parts if p) - return full or user_dict.get("email") or "" - - def _user_display_name(user: User) -> str: full = (user.get_full_name() or "").strip() return full or user.email @@ -371,7 +362,9 @@ def authorize_and_commit( cls._commit(resource, desired) return - is_owner = resource.created_by_id == actor.pk + # Ownership is role-based (OWNER membership), not ``created_by``, since + # UN-2202 — ``created_by`` is audit-only. + is_owner = resource.is_owner(actor) is_admin = is_org_admin(actor) cls._authorize(actor, resource, desired, is_owner, is_admin) cls._commit(resource, desired) @@ -533,17 +526,30 @@ def _diff_id_axis( @staticmethod def _current_ids(resource: Any, axis: str) -> set[int]: + if axis == ShareAuthorizationService.USERS_AXIS: + return ShareAuthorizationService._current_viewer_ids(resource) if axis == ShareAuthorizationService.GROUPS_AXIS: return set(get_resource_share_groups(resource).values_list("id", flat=True)) return set(getattr(resource, axis).values_list("pk", flat=True)) + @staticmethod + def _current_viewer_ids(resource: Any) -> set[int]: + """User ids holding a VIEWER membership row on ``resource``.""" + from permissions.roles import ResourceRole + + return set( + resource.memberships.filter(role=ResourceRole.VIEWER).values_list( + "user_id", flat=True + ) + ) + # ----------------------------------------------------------------- write @classmethod @transaction.atomic def _commit(cls, resource: Any, desired: dict[str, Any]) -> None: if cls.USERS_AXIS in desired: - getattr(resource, cls.USERS_AXIS).set(desired[cls.USERS_AXIS] or []) + cls._set_viewer_members(resource, desired[cls.USERS_AXIS] or []) if cls.GROUPS_AXIS in desired: set_resource_share_groups(resource, desired[cls.GROUPS_AXIS] or []) if cls.ORG_AXIS in desired: @@ -552,6 +558,31 @@ def _commit(cls, resource: Any, desired: dict[str, Any]) -> None: setattr(resource, cls.ORG_AXIS, new_value) resource.save(update_fields=[cls.ORG_AXIS, "modified_at"]) + @staticmethod + def _set_viewer_members(resource: Any, desired_ids: Iterable[int]) -> None: + """Replace ``resource``'s VIEWER membership rows to match ``desired_ids``. + + Mirrors M2M ``.set()`` semantics over the membership table. OWNER rows + are never touched: a desired id that is already an OWNER is left as-is + (``unique_together`` forbids a second row), so owners are never demoted + or duplicated (UN-2202 Phase 2). + """ + from permissions.roles import ResourceRole + + desired = {int(pk) for pk in desired_ids or ()} + memberships = resource.memberships + current_viewer_ids = set( + memberships.filter(role=ResourceRole.VIEWER).values_list("user_id", flat=True) + ) + to_remove = current_viewer_ids - desired + if to_remove: + memberships.filter(role=ResourceRole.VIEWER, user_id__in=to_remove).delete() + for user_id in desired - current_viewer_ids: + # get_or_create: an existing OWNER row is returned untouched. + memberships.get_or_create( + user_id=user_id, defaults={"role": ResourceRole.VIEWER} + ) + # ------------------------------------------------------------ exceptions @staticmethod diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py index a705a04777..105ae9284c 100644 --- a/backend/tenant_account_v2/signals.py +++ b/backend/tenant_account_v2/signals.py @@ -3,9 +3,10 @@ from django.apps import apps from django.core.exceptions import FieldDoesNotExist from django.db import transaction -from django.db.models.fields.related import ManyToManyRel +from django.db.models import ManyToManyField from django.db.models.signals import post_delete from django.dispatch import receiver +from permissions.roles import ResourceRole from tenant_account_v2.models import GroupMembership, OrganizationMember from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES @@ -22,9 +23,11 @@ def cleanup_user_org_access( Two cleanups: 1. Group memberships for that org (group-derived access goes away live via ``for_user()``). - 2. Direct ``shared_users`` M2M entries on every shareable resource of - that org — closes the rejoin backdoor where a re-invited user would - silently regain direct access. + 2. Direct VIEWER membership rows on every shareable resource of that org + — closes the rejoin backdoor where a re-invited user would silently + regain direct access. OWNER rows are left intact (parity with the old + ``shared_users``-only purge, where ``created_by`` ownership survived + re-invite); a departing owner's resources stay admin-manageable. Uses a signal (not DB CASCADE) so notification / audit hooks can attach here later without a schema change. The whole purge runs in one @@ -51,31 +54,32 @@ def cleanup_user_org_access( # App not installed in this deployment (e.g. cloud-only # agentic_studio_v1 in pure OSS). Skip cleanly. continue - # Delete via the M2M through table, not ``model.objects``: the - # default manager is org-scoped on ``UserContext`` (None outside + # Delete via the membership through table, not ``model.objects``: + # the default manager is org-scoped on ``UserContext`` (None outside # an HTTP request), so it would match zero rows in tests / # management commands. The through manager is unscoped; scope it # explicitly by the resource's own organization. try: - m2m_rel = model._meta.get_field("shared_users").remote_field + members_field = model._meta.get_field("members") except FieldDoesNotExist: - # A registered model can legitimately lack the sharing field + # A registered model can legitimately lack the membership field # during the OSS<->cloud sync window (e.g. AgenticProject - # before #1508 applies its migration). Group memberships were - # already purged above; skip the direct-share purge here. + # before its migration applies). Group memberships were already + # purged above; skip the direct-share purge here. continue - assert isinstance(m2m_rel, ManyToManyRel) - through = m2m_rel.through - source_fk = model._meta.model_name + assert isinstance(members_field, ManyToManyField) + through = members_field.remote_field.through + source_fk = members_field.m2m_field_name() # e.g. "adapter" try: removed, _ = through.objects.filter( user=instance.user, + role=ResourceRole.VIEWER, **{f"{source_fk}__organization": instance.organization}, ).delete() except Exception: logger.exception( - "Failed purging shared_users for user=%s on %s.%s org=%s; " - "rolling back the whole purge", + "Failed purging VIEWER memberships for user=%s on %s.%s " + "org=%s; rolling back the whole purge", instance.user_id, resource.app_label, resource.model_name, @@ -84,9 +88,9 @@ def cleanup_user_org_access( raise if removed: logger.info( - "Removed user=%s from shared_users on %s %s.%s rows in org=%s", - instance.user_id, + "Removed %s VIEWER memberships for user=%s on %s.%s in org=%s", removed, + instance.user_id, resource.app_label, resource.model_name, instance.organization_id, diff --git a/backend/tenant_account_v2/tests.py b/backend/tenant_account_v2/tests.py index e6b0f70229..10e7aa933b 100644 --- a/backend/tenant_account_v2/tests.py +++ b/backend/tenant_account_v2/tests.py @@ -17,10 +17,11 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.test import TestCase +from permissions.roles import ResourceRole from rest_framework.exceptions import PermissionDenied from rest_framework.test import APIRequestFactory, force_authenticate from utils.user_context import UserContext -from workflow_manager.workflow_v2.models.workflow import Workflow +from workflow_manager.workflow_v2.models.workflow import Workflow, WorkflowMember from tenant_account_v2.group_views import OrganizationGroupViewSet from tenant_account_v2.models import ( @@ -49,7 +50,20 @@ def _shared_group_ids(resource) -> set[int]: def _shared_user_ids(resource) -> set[int]: - return set(resource.shared_users.values_list("id", flat=True)) + """Direct-viewer user ids (VIEWER memberships, UN-2202).""" + return set( + resource.memberships.filter(role=ResourceRole.VIEWER).values_list( + "user_id", flat=True + ) + ) + + +def _add_viewers(resource, *users) -> None: + """Add direct viewers as VIEWER rows (successor to ``shared_users.add``).""" + for user in users: + resource.memberships.get_or_create( + user=user, defaults={"role": ResourceRole.VIEWER} + ) class GroupSharingTestBase(TestCase): @@ -81,6 +95,10 @@ def setUp(self) -> None: self.workflow = Workflow.objects.create( workflow_name="wf-1", organization=self.org, created_by=self.owner ) + # Creator's access flows through an OWNER membership row (UN-2202). + WorkflowMember.objects.create( + workflow=self.workflow, user=self.owner, role=ResourceRole.OWNER + ) class ShareAuthorizationServiceTests(GroupSharingTestBase): @@ -116,12 +134,12 @@ def test_owner_can_add_group_and_toggle_org(self) -> None: self.assertTrue(self.workflow.shared_to_org) def test_admin_can_remove_users(self) -> None: - self.workflow.shared_users.add(self.member) + _add_viewers(self.workflow, self.member) self._authorize(self.admin, {"shared_users": []}) self.assertEqual(_shared_user_ids(self.workflow), set()) def test_unprivileged_cannot_remove_users(self) -> None: - self.workflow.shared_users.add(self.member, self.outsider) + _add_viewers(self.workflow, self.member, self.outsider) with self.assertRaises(PermissionDenied): # outsider (a shared user, not owner/admin) tries to drop member self._authorize(self.outsider, {"shared_users": [self.outsider.id]}) @@ -156,7 +174,7 @@ def test_service_account_bypasses_authorization(self) -> None: def test_authorize_is_atomic_on_partial_denial(self) -> None: """A denial on any axis must leave every axis uncommitted.""" - self.workflow.shared_users.add(self.outsider) + _add_viewers(self.workflow, self.outsider) with self.assertRaises(PermissionDenied): # users add is allowed for a shared user, but the org toggle isn't self._authorize( @@ -229,7 +247,7 @@ class SignalCleanupTests(GroupSharingTestBase): """The two ``post_delete`` cleanups: rejoin-backdoor and orphan prevention.""" def test_org_member_removal_purges_memberships_and_direct_shares(self) -> None: - self.workflow.shared_users.add(self.member) + _add_viewers(self.workflow, self.member) set_resource_share_groups(self.workflow, [self.group.id]) OrganizationMember.objects.get(user=self.member).delete() diff --git a/backend/tool_instance_v2/tool_instance_helper.py b/backend/tool_instance_v2/tool_instance_helper.py index f873b0d8bc..a9c126dac4 100644 --- a/backend/tool_instance_v2/tool_instance_helper.py +++ b/backend/tool_instance_v2/tool_instance_helper.py @@ -9,7 +9,11 @@ from django.core.exceptions import PermissionDenied from django.core.exceptions import ValidationError as DjangoValidationError from jsonschema.exceptions import ValidationError as JSONValidationError -from permissions.permission import has_group_access +from permissions.permission import ( + _is_resource_owner, + _is_resource_viewer, + has_group_access, +) from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry from tenant_account_v2.organization_member_service import OrganizationMemberService from workflow_manager.workflow_v2.constants import WorkflowKey @@ -507,8 +511,8 @@ def validate_adapter_access( if not ( is_admin or adapter_instance.shared_to_org - or adapter_instance.created_by == user - or adapter_instance.shared_users.filter(pk=user.pk).exists() + or _is_resource_owner(user, adapter_instance) + or _is_resource_viewer(user, adapter_instance) or has_group_access(user, adapter_instance) ): logger.error( diff --git a/backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py b/backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py new file mode 100644 index 0000000000..0647c89146 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py @@ -0,0 +1,103 @@ +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 workflow's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Workflows with a null ``created_by`` are skipped. + """ + Workflow = apps.get_model("workflow_v2", "Workflow") + WorkflowMember = apps.get_model("workflow_v2", "WorkflowMember") + skipped = 0 + for workflow in Workflow.objects.iterator(): + if not workflow.created_by_id: + skipped += 1 + continue + WorkflowMember.objects.get_or_create( + workflow=workflow, + user_id=workflow.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s workflows with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("workflow_v2", "0021_alter_workflow_organization"), + ] + + operations = [ + migrations.CreateModel( + name="WorkflowMember", + 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)), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="workflow_v2.workflow", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "workflow_member", + "unique_together": {("user", "workflow")}, + }, + ), + migrations.AddField( + model_name="workflow", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this workflow.", + related_name="workflows_member_of", + through="workflow_v2.WorkflowMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="workflowmember", + index=models.Index( + fields=["workflow", "role"], name="workflow_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/workflow_manager/workflow_v2/migrations/0023_workflow_absorb_shared_users.py b/backend/workflow_manager/workflow_v2/migrations/0023_workflow_absorb_shared_users.py new file mode 100644 index 0000000000..c106736c3d --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0023_workflow_absorb_shared_users.py @@ -0,0 +1,40 @@ +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``). + """ + Workflow = apps.get_model("workflow_v2", "Workflow") + WorkflowMember = apps.get_model("workflow_v2", "WorkflowMember") + migrated = 0 + for workflow in Workflow.objects.iterator(): + for user_id in workflow.shared_users.values_list("id", flat=True): + _, created = WorkflowMember.objects.get_or_create( + workflow=workflow, 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 = [ + ("workflow_v2", "0022_workflow_co_owner_membership"), + ] + + operations = [ + migrations.RunPython(backfill_shared_users_as_viewers, migrations.RunPython.noop), + migrations.RemoveField( + model_name="workflow", + name="shared_users", + ), + ] diff --git a/backend/workflow_manager/workflow_v2/models/__init__.py b/backend/workflow_manager/workflow_v2/models/__init__.py index cd3a65e189..1bb32a4335 100644 --- a/backend/workflow_manager/workflow_v2/models/__init__.py +++ b/backend/workflow_manager/workflow_v2/models/__init__.py @@ -1,7 +1,7 @@ # isort:skip_file # Do not change the order of the imports below to avoid circular dependency issues -from .workflow import Workflow # noqa: F401 +from .workflow import Workflow, WorkflowMember # noqa: F401 from .execution import WorkflowExecution # noqa: F401 from .execution_log import ExecutionLog # noqa: F401 from .file_history import FileHistory # noqa: F401 diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index e59e8faebb..6f1ffa84ea 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -63,24 +63,21 @@ def for_user(self, user) -> QuerySet: return self.filter(workflow__organization=org) return self.all() - # Filter for workflow access - workflow_filter = Q(workflow__created_by=user) | Q(workflow__shared_users=user) + # Filter for workflow access (owner or direct viewer via membership). + # ``created_by`` is audit-only (UN-2202); VIEWER rows replaced shared_users. + workflow_filter = Q(workflow__members=user) # Filter for API deployments the user can access api_filter = Q( pipeline_id__in=models.Subquery( - APIDeployment.objects.filter( - Q(created_by=user) | Q(shared_users=user) - ).values("id") + APIDeployment.objects.filter(Q(members=user)).values("id") ) ) # Filter for Pipelines the user can access pipeline_filter = Q( pipeline_id__in=models.Subquery( - Pipeline.objects.filter(Q(created_by=user) | Q(shared_users=user)).values( - "id" - ) + Pipeline.objects.filter(Q(members=user)).values("id") ) ) diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 1b2891937e..82f96e7269 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -5,6 +5,7 @@ from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -37,14 +38,13 @@ def for_user(self, user): 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.filter( - Q(created_by=user) # Owned by user - | Q(shared_users=user) # Shared with user + Q(members=user) # Owner or direct viewer (created_by is audit-only) | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class Workflow(DefaultOrganizationMixin, BaseModel): +class Workflow(HasMembersMixin, DefaultOrganizationMixin, BaseModel): class WorkflowType(models.TextChoices): DEFAULT = "DEFAULT", "Not ready yet" ETL = "ETL", "ETL pipeline" @@ -105,9 +105,6 @@ class ExecutionAction(models.TextChoices): ) # Sharing fields - shared_users = models.ManyToManyField( - User, related_name="shared_workflows", blank=True - ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this workflow is shared with the entire organization", @@ -122,6 +119,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the WorkflowMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="WorkflowMember", + related_name="workflows_member_of", + help_text="Users with a role (owner/viewer) on this workflow.", + ) + # Manager objects = WorkflowModelManager() @@ -153,3 +159,20 @@ class Meta: name="unique_workflow_name", ), ] + + +class WorkflowMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``Workflow``.""" + + workflow = models.ForeignKey( + Workflow, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "workflow_member" + unique_together = [("user", "workflow")] + indexes = [ + models.Index(fields=["workflow", "role"], name="workflow_member_role_idx") + ] diff --git a/backend/workflow_manager/workflow_v2/permissions.py b/backend/workflow_manager/workflow_v2/permissions.py index 3d957b6ea5..01e0cb0ca0 100644 --- a/backend/workflow_manager/workflow_v2/permissions.py +++ b/backend/workflow_manager/workflow_v2/permissions.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from permissions.permission import _is_resource_owner, _is_resource_viewer from rest_framework.permissions import BasePermission from tenant_account_v2.organization_member_service import OrganizationMemberService @@ -9,9 +10,9 @@ class IsWorkflowOwnerOrShared(BasePermission): """Permission class to check if user has access to a workflow. Checks: - 1. User is the workflow owner (created_by) - 2. User has shared access (in shared_users) - 3. Workflow is shared to user's organization (shared_to_org) + 1. User owns the workflow (OWNER membership; ``created_by`` is audit-only). + 2. User is a direct viewer (VIEWER membership). + 3. Workflow is shared to user's organization (shared_to_org). Caches the workflow on request object to avoid duplicate fetching. """ @@ -29,7 +30,7 @@ def has_permission(self, request, view): request._workflow_cache = get_object_or_404( Workflow.objects.select_related( "created_by", "organization" - ).prefetch_related("shared_users"), + ).prefetch_related("memberships__user"), id=workflow_id, ) @@ -37,8 +38,8 @@ def has_permission(self, request, view): user = request.user has_access = ( - workflow.created_by == user - or user in workflow.shared_users.all() + _is_resource_owner(user, workflow) + or _is_resource_viewer(user, workflow) or (workflow.shared_to_org and workflow.organization == user.organization) or OrganizationMemberService.is_user_organization_admin(user) ) diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index 47edf54d66..ad7eda543e 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -48,7 +48,6 @@ class Meta: WorkflowKey.LLM_RESPONSE: { "required": False, }, - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -80,6 +79,9 @@ def to_representation(self, instance: Workflow) -> dict[str, str]: representation["created_by_email"] = ( instance.created_by.email if instance.created_by else None ) + request = self.context.get("request") + representation["is_owner"] = instance.is_owner(request.user) if request else False + representation["co_owners_count"] = instance.co_owners_count() return representation def create(self, validated_data: dict[str, Any]) -> Any: @@ -187,6 +189,7 @@ class SharedUserListSerializer(ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: @@ -197,19 +200,24 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", ] def get_shared_users(self, obj): - """Return list of shared users with id and email.""" + """Return direct viewers (VIEWER members) with id and email.""" return [ {"id": user.id, "email": user.email} - for user in obj.shared_users.filter(is_service_account=False) + for user in obj.viewers() + if not user.is_service_account ] def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return [{"id": u.id, "email": u.email} for u in obj.owners()] + def get_created_by(self, obj): """Return creator details.""" if obj.created_by: diff --git a/backend/workflow_manager/workflow_v2/urls/workflow.py b/backend/workflow_manager/workflow_v2/urls/workflow.py index 1225a4a32c..7eee26821e 100644 --- a/backend/workflow_manager/workflow_v2/urls/workflow.py +++ b/backend/workflow_manager/workflow_v2/urls/workflow.py @@ -28,6 +28,8 @@ list_shared_users = WorkflowViewSet.as_view({"get": "list_of_shared_users"}) workflow_share = WorkflowViewSet.as_view({"post": "share"}) workflow_effective_members = WorkflowViewSet.as_view({"get": "effective_members"}) +workflow_add_owner = WorkflowViewSet.as_view({"post": "add_co_owner"}) +workflow_remove_owner = WorkflowViewSet.as_view({"delete": "remove_co_owner"}) # File History views file_history_list = FileHistoryViewSet.as_view({"get": "list"}) @@ -59,6 +61,12 @@ workflow_effective_members, name="workflow-effective-members", ), + path("/owners/", workflow_add_owner, name="workflow-add-owner"), + path( + "/owners//", + workflow_remove_owner, + name="workflow-remove-owner", + ), path("execute/", workflow_execute, name="execute-workflow"), path( "active//", diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index e0822e966f..acbdc548fc 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -7,8 +7,10 @@ from django.db.models.query import QuerySet from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt +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 pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor from plugins import get_plugin @@ -69,11 +71,25 @@ def make_execution_response(response: ExecutionResponse) -> Any: return ExecuteWorkflowResponseSerializer(response).data -class WorkflowViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class WorkflowViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): versioning_class = URLPathVersioning + notification_resource_name_field = "workflow_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + if not notification_plugin: + return None + return ResourceType.WORKFLOW.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()] @@ -129,6 +145,11 @@ def perform_create(self, serializer: WorkflowSerializer) -> Workflow: workflow = serializer.save( is_active=True, ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + workflow.memberships.get_or_create( + user_id=self.request.user.id, defaults={"role": ResourceRole.OWNER} + ) try: # Create empty WorkflowEndpoints for UI compatibility # ConnectorInstances will be created when users actually configure connectors diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx index c4bf0d1684..db6b88ae8d 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -4,18 +4,20 @@ import PropTypes from "prop-types"; import { useEffect, useMemo, useState } from "react"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement"; +import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; +import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; import { groupsService } from "../../groups/groups-service.js"; +import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; +import { SharePermission } from "../../widgets/share-permission/SharePermission"; import { AddCustomToolFormModal } from "../add-custom-tool-form-modal/AddCustomToolFormModal"; +import { ImportTool } from "../import-tool/ImportTool"; import { ViewTools } from "../view-tools/ViewTools"; import "./ListOfTools.css"; -import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; -import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; -import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar"; -import { SharePermission } from "../../widgets/share-permission/SharePermission"; -import { ImportTool } from "../import-tool/ImportTool"; const DefaultCustomButtons = ({ setOpenImportTool, @@ -71,6 +73,54 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const [isPermissionEdit, setIsPermissionEdit] = useState(false); const [isShareLoading, setIsShareLoading] = useState(false); const [allUserList, setAllUserList] = useState([]); + const promptStudioCoOwnerService = useMemo( + () => ({ + getAllUsers: () => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/users/`, + }), + getSharedUsers: (id) => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/users/${id}`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + addCoOwner: (id, userId) => + axiosPrivate({ + method: "POST", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${id}/owners/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: { user_id: userId }, + }), + removeCoOwner: (id, userId) => + axiosPrivate({ + method: "DELETE", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${id}/owners/${userId}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + }), + [axiosPrivate, sessionDetails?.orgId, sessionDetails?.csrfToken], + ); + + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: promptStudioCoOwnerService, + setAlertDetails, + onListRefresh: () => getListOfTools(), + }); const [allGroupList, setAllGroupList] = useState([]); useEffect(() => { @@ -84,7 +134,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const getListOfTools = () => { const requestOptions = { method: "GET", - url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/ `, + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/`, headers: { "X-CSRFToken": sessionDetails?.csrfToken, }, @@ -354,6 +404,10 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { }); }; + const handleCoOwner = (_event, tool) => { + handleCoOwnerAction(tool.tool_id); + }; + const defaultContent = (
); @@ -426,6 +481,18 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { onApply={onShare} isSharableToOrg={true} /> + ); } diff --git a/frontend/src/components/custom-tools/view-tools/ViewTools.jsx b/frontend/src/components/custom-tools/view-tools/ViewTools.jsx index 6317f484ff..5dbf92d8f0 100644 --- a/frontend/src/components/custom-tools/view-tools/ViewTools.jsx +++ b/frontend/src/components/custom-tools/view-tools/ViewTools.jsx @@ -19,6 +19,7 @@ function ViewTools({ centered, isClickable = true, handleShare, + handleCoOwner, showOwner, type, }) { @@ -58,6 +59,7 @@ function ViewTools({ centered={centered} isClickable={isClickable} handleShare={handleShare} + handleCoOwner={handleCoOwner} showOwner={showOwner} type={type} /> @@ -72,6 +74,7 @@ ViewTools.propTypes = { handleEdit: PropTypes.func.isRequired, handleDelete: PropTypes.func.isRequired, handleShare: PropTypes.func, + handleCoOwner: PropTypes.func, titleProp: PropTypes.string.isRequired, descriptionProp: PropTypes.string, iconProp: PropTypes.string, diff --git a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx index 50ae379e89..b70c18edd1 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx @@ -3,6 +3,7 @@ import { useLocation } from "react-router-dom"; import { deploymentApiTypes, displayURL } from "../../../helpers/GetStaticData"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; import { useExecutionLogs } from "../../../hooks/useExecutionLogs"; import { usePaginatedList } from "../../../hooks/usePaginatedList"; @@ -21,6 +22,7 @@ import { PromptStudioModal } from "../../common/PromptStudioModal"; import { groupsService } from "../../groups/groups-service.js"; import { LogsModal } from "../../pipelines-or-deployments/log-modal/LogsModal.jsx"; import { NotificationModal } from "../../pipelines-or-deployments/notification-modal/NotificationModal.jsx"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; import { workflowService } from "../../workflows/workflow/workflow-service.js"; import { CreateApiDeploymentModal } from "../create-api-deployment-modal/CreateApiDeploymentModal"; @@ -50,6 +52,21 @@ function ApiDeployment() { const axiosPrivate = useAxiosPrivate(); const { getApiKeys, downloadPostmanCollection } = usePipelineHelper(); const [openNotificationModal, setOpenNotificationModal] = useState(false); + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: apiDeploymentsApiService, + setAlertDetails, + onListRefresh: () => getApiDeploymentList(), + }); const { count, isLoading, fetchCount } = usePromptStudioStore(); const { getPromptStudioCount } = usePromptStudioService(); @@ -249,6 +266,11 @@ function ApiDeployment() { downloadPostmanCollection(apiDeploymentsApiService, deployment.id); }; + const handleManageCoOwners = (deployment) => { + if (!deployment?.id) return; + handleCoOwnerAction(deployment.id); + }; + // Card view configuration const apiDeploymentCardConfig = useMemo( () => @@ -265,6 +287,7 @@ function ApiDeployment() { onSetupNotifications: handleSetupNotificationsDeployment, onCodeSnippets: handleCodeSnippetsDeployment, onDownloadPostman: handleDownloadPostmanDeployment, + onManageCoOwners: handleManageCoOwners, listContext: { page: pagination.current, pageSize: pagination.pageSize, @@ -363,6 +386,18 @@ function ApiDeployment() { onApply={onShare} isSharableToOrg={true} /> + ); } diff --git a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx index e5c9d67966..e08b86da75 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx @@ -38,6 +38,7 @@ function createApiDeploymentCardConfig({ onSetupNotifications, onCodeSnippets, onDownloadPostman, + onManageCoOwners, listContext, }) { return { @@ -128,7 +129,11 @@ function createApiDeploymentCardConfig({ itemId={deployment.id} listContext={listContext} /> - + onManageCoOwners?.(deployment)} + /> { + options = { + method: "POST", + url: `${path}/api/deployment/${id}/owners/`, + headers: requestHeaders, + data: { user_id: userId }, + }; + return axiosPrivate(options); + }, + removeCoOwner: (id, userId) => { + options = { + method: "DELETE", + url: `${path}/api/deployment/${id}/owners/${userId}/`, + headers: requestHeaders, + }; + return axiosPrivate(options); + }, }; } diff --git a/frontend/src/components/pipelines-or-deployments/pipeline-service.js b/frontend/src/components/pipelines-or-deployments/pipeline-service.js index db4ef62338..4858ab41ba 100644 --- a/frontend/src/components/pipelines-or-deployments/pipeline-service.js +++ b/frontend/src/components/pipelines-or-deployments/pipeline-service.js @@ -126,6 +126,23 @@ function pipelineService() { }; return axiosPrivate(requestOptions); }, + addCoOwner: (id, userId) => { + const requestOptions = { + method: "POST", + url: `${path}/pipeline/${id}/owners/`, + headers: requestHeaders, + data: { user_id: userId }, + }; + return axiosPrivate(requestOptions); + }, + removeCoOwner: (id, userId) => { + const requestOptions = { + method: "DELETE", + url: `${path}/pipeline/${id}/owners/${userId}/`, + headers: requestHeaders, + }; + return axiosPrivate(requestOptions); + }, }; } diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx index 6680c1df5b..2125041df1 100644 --- a/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx +++ b/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx @@ -240,6 +240,7 @@ function createPipelineCardConfig({ onManageKeys, onSetupNotifications, onDownloadPostman, + onManageCoOwners, isClearingFileHistory, pipelineType, listContext, @@ -369,7 +370,11 @@ function createPipelineCardConfig({ itemId={pipeline.id} listContext={listContext} /> - + onManageCoOwners?.(pipeline)} + /> {/* NEXT RUN AT row (only if scheduled) */} diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx index ec7bc779e1..9cf4703a28 100644 --- a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx +++ b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx @@ -7,14 +7,8 @@ import { deploymentsStaticContent, } from "../../../helpers/GetStaticData"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js"; -import { useAlertStore } from "../../../store/alert-store.js"; -import { useSessionStore } from "../../../store/session-store.js"; -import { Layout } from "../../deployments/layout/Layout.jsx"; -import { EtlTaskDeploy } from "../etl-task-deploy/EtlTaskDeploy.jsx"; -import FileHistoryModal from "../file-history-modal/FileHistoryModal.jsx"; -import { LogsModal } from "../log-modal/LogsModal.jsx"; -import "./Pipelines.css"; import useClearFileHistory from "../../../hooks/useClearFileHistory"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; import { useExecutionLogs } from "../../../hooks/useExecutionLogs"; import { usePaginatedList } from "../../../hooks/usePaginatedList"; @@ -25,15 +19,23 @@ import { } from "../../../hooks/usePromptStudioFetchCount"; import { useScrollRestoration } from "../../../hooks/useScrollRestoration"; import { useShareModal } from "../../../hooks/useShareModal"; +import { useAlertStore } from "../../../store/alert-store.js"; import { usePromptStudioStore } from "../../../store/prompt-studio-store"; +import { useSessionStore } from "../../../store/session-store.js"; import { usePromptStudioService } from "../../api/prompt-studio-service"; import { PromptStudioModal } from "../../common/PromptStudioModal"; +import { Layout } from "../../deployments/layout/Layout.jsx"; import { ManageKeys } from "../../deployments/manage-keys/ManageKeys.jsx"; import { groupsService } from "../../groups/groups-service.js"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; +import { EtlTaskDeploy } from "../etl-task-deploy/EtlTaskDeploy.jsx"; +import FileHistoryModal from "../file-history-modal/FileHistoryModal.jsx"; +import { LogsModal } from "../log-modal/LogsModal.jsx"; import { NotificationModal } from "../notification-modal/NotificationModal.jsx"; import { pipelineService } from "../pipeline-service.js"; import { createPipelineCardConfig } from "./PipelineCardConfig.jsx"; +import "./Pipelines.css"; function Pipelines({ type }) { const [tableData, setTableData] = useState([]); @@ -54,6 +56,21 @@ function Pipelines({ type }) { const pipelineApiService = pipelineService(); const { getApiKeys, downloadPostmanCollection } = usePipelineHelper(); const [openNotificationModal, setOpenNotificationModal] = useState(false); + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: pipelineApiService, + setAlertDetails, + onListRefresh: () => getPipelineList(), + }); const { count, isLoading, fetchCount } = usePromptStudioStore(); const { getPromptStudioCount } = usePromptStudioService(); @@ -315,6 +332,11 @@ function Pipelines({ type }) { downloadPostmanCollection(pipelineApiService, pipeline.id); }; + const handleManageCoOwners = (pipeline) => { + if (!pipeline?.id) return; + handleCoOwnerAction(pipeline.id); + }; + // Card view configuration - no actionItems needed, all handlers passed directly const pipelineCardConfig = useMemo( () => @@ -335,6 +357,7 @@ function Pipelines({ type }) { onManageKeys: handleManageKeysPipeline, onSetupNotifications: handleSetupNotificationsPipeline, onDownloadPostman: handleDownloadPostmanPipeline, + onManageCoOwners: handleManageCoOwners, // Loading states isClearingFileHistory, // Pipeline type for status pill navigation @@ -442,6 +465,20 @@ function Pipelines({ type }) { isSharableToOrg={true} /> )} + {coOwnerOpen && ( + + )} ); } diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx index 46c6153fee..e8cf142e21 100644 --- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx @@ -1,22 +1,24 @@ import { PlusOutlined } from "@ant-design/icons"; import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -import { IslandLayout } from "../../../layouts/island-layout/IslandLayout"; -import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal"; -import "../../input-output/data-source-card/DataSourceCard.css"; -import "./ToolSettings.css"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useListSearch } from "../../../hooks/useListSearch"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; +import { IslandLayout } from "../../../layouts/island-layout/IslandLayout"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; import { ViewTools } from "../../custom-tools/view-tools/ViewTools"; import { groupsService } from "../../groups/groups-service.js"; +import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal"; +import "../../input-output/data-source-card/DataSourceCard.css"; import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; +import "./ToolSettings.css"; const titles = { llm: "LLMs", @@ -50,6 +52,55 @@ function ToolSettings({ type }) { const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); const handleException = useExceptionHandler(); + + const adapterCoOwnerService = useMemo( + () => ({ + getAllUsers: () => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/users/`, + }), + getSharedUsers: (id) => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/users/${id}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + addCoOwner: (id, userId) => + axiosPrivate({ + method: "POST", + url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${id}/owners/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: { user_id: userId }, + }), + removeCoOwner: (id, userId) => + axiosPrivate({ + method: "DELETE", + url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${id}/owners/${userId}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + }), + [sessionDetails?.orgId, sessionDetails?.csrfToken], + ); + + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: adapterCoOwnerService, + setAlertDetails, + onListRefresh: () => getAdapters(), + }); const { posthogEventText, setPostHogCustomEvent } = usePostHogEvents(); const { listRef, @@ -216,6 +267,18 @@ function ToolSettings({ type }) { }); }; + const handleCoOwner = (_event, adapter) => { + if (!adapter?.id) return; + if (adapter?.is_deprecated) { + setAlertDetails({ + type: "error", + content: "This adapter has been deprecated and cannot be managed.", + }); + return; + } + handleCoOwnerAction(adapter.id); + }; + const handleOpenAddSourceModal = () => { setOpenAddSourcesModal(true); @@ -274,6 +337,7 @@ function ToolSettings({ type }) { centered isClickable={false} handleShare={handleShare} + handleCoOwner={handleCoOwner} showOwner={true} type="Adapter" /> @@ -299,6 +363,18 @@ function ToolSettings({ type }) { onApply={onShare} isSharableToOrg={true} /> + ); } diff --git a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx index 15ca6873cc..bffe1b747e 100644 --- a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx +++ b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx @@ -117,22 +117,44 @@ CardActionBox.propTypes = { * Reusable owner field row * @return {JSX.Element} Rendered owner field row */ -function OwnerFieldRow({ item, sessionDetails }) { - const isOwner = item.created_by === sessionDetails?.userId; +function OwnerFieldRow({ item, sessionDetails, onManageCoOwners }) { + const isOwner = item?.is_owner ?? item.created_by === sessionDetails?.userId; const email = item.created_by_email; - const ownerDisplay = isOwner ? "You" : email?.split("@")[0] || "Unknown"; + const name = isOwner ? "Me" : email?.split("@")[0] || "Unknown"; + const extra = + item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : ""; + const ownerDisplay = `${name}${extra}`; + + const ownerContent = ( + + + + {ownerDisplay} + + + ); return ( Owner - - - - {ownerDisplay} + {onManageCoOwners ? ( + + - + ) : ( + ownerContent + )} ); } @@ -140,6 +162,7 @@ function OwnerFieldRow({ item, sessionDetails }) { OwnerFieldRow.propTypes = { item: PropTypes.object.isRequired, sessionDetails: PropTypes.object, + onManageCoOwners: PropTypes.func, }; /** diff --git a/frontend/src/components/widgets/card-grid-view/CardGridView.css b/frontend/src/components/widgets/card-grid-view/CardGridView.css index 9627e3c38a..f51319cf2f 100644 --- a/frontend/src/components/widgets/card-grid-view/CardGridView.css +++ b/frontend/src/components/widgets/card-grid-view/CardGridView.css @@ -896,6 +896,25 @@ color: #1677ff; } +/* Clickable owner field - mirrors ListView owner badge behavior */ +.card-owner-clickable { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s ease; +} + +.card-owner-clickable:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.card-owner-clickable:hover .ant-typography { + color: #1677ff; +} + /* Responsive override for card-grid-view on smaller screens */ @media (max-width: 900px) { .card-grid-view { diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css new file mode 100644 index 0000000000..c7698eea62 --- /dev/null +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css @@ -0,0 +1,17 @@ +.co-owner-search { + width: 100%; + margin-bottom: 16px; +} + +.co-owner-creator-tag { + margin-left: 8px; +} + +.co-owner-modal .shared-user-avatar { + background-color: #00a6ed; + margin-right: 15px; +} + +.co-owner-modal .shared-username { + font-weight: 500; +} diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx new file mode 100644 index 0000000000..5b56ca7a7c --- /dev/null +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx @@ -0,0 +1,232 @@ +import { + DeleteOutlined, + QuestionCircleOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { + Avatar, + Button, + List, + Modal, + Popconfirm, + Select, + Typography, +} from "antd"; +import PropTypes from "prop-types"; +import { useMemo, useState } from "react"; + +import { SpinnerLoader } from "../spinner-loader/SpinnerLoader"; +import "./CoOwnerManagement.css"; + +function CoOwnerManagement({ + open, + setOpen, + resourceId, + resourceType, + allUsers, + coOwners, + loading, + onAddCoOwner, + onRemoveCoOwner, +}) { + const [pendingAdds, setPendingAdds] = useState([]); + const [removingUserId, setRemovingUserId] = useState(null); + const [applying, setApplying] = useState(false); + + const ownersList = coOwners || []; + const totalOwners = ownersList.length; + + // Exclude both existing co-owners and pending adds from dropdown + const availableUsers = useMemo(() => { + const coOwnerIds = new Set((coOwners || []).map((u) => u?.id?.toString())); + const pendingIds = new Set(pendingAdds.map((u) => u?.id?.toString())); + return (allUsers || []).filter( + (user) => + !coOwnerIds.has(user?.id?.toString()) && + !pendingIds.has(user?.id?.toString()), + ); + }, [allUsers, coOwners, pendingAdds]); + + const handleSelect = (userId) => { + const user = (allUsers || []).find( + (u) => u?.id?.toString() === userId?.toString(), + ); + if (user) { + setPendingAdds((prev) => [...prev, user]); + } + }; + + const handleRemovePending = (userId) => { + setPendingAdds((prev) => + prev.filter((u) => u?.id?.toString() !== userId?.toString()), + ); + }; + + const handleRemoveExisting = async (userId) => { + setRemovingUserId(userId); + try { + await onRemoveCoOwner(resourceId, userId); + } finally { + setRemovingUserId(null); + } + }; + + const handleApply = async () => { + if (pendingAdds.length === 0) return; + const usersToAdd = [...pendingAdds]; + setApplying(true); + try { + const userIds = usersToAdd.map((user) => user.id); + await onAddCoOwner(resourceId, userIds); + } finally { + setPendingAdds([]); + setApplying(false); + } + }; + + const handleCancel = () => { + setPendingAdds([]); + setOpen(false); + }; + + const filterOption = (input, option) => + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()); + + const combinedList = [ + ...ownersList, + ...pendingAdds.filter( + (pending) => + !ownersList.some( + (owner) => owner?.id?.toString() === pending?.id?.toString(), + ), + ), + ]; + + return ( + + {loading || applying ? ( + + ) : ( + <> +