diff --git a/backend/adapter_processor_v2/migrations/0004_adapterinstance_co_owners.py b/backend/adapter_processor_v2/migrations/0004_adapterinstance_co_owners.py new file mode 100644 index 0000000000..a09a426f9b --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0004_adapterinstance_co_owners.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.1 on 2026-02-17 08:24 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("adapter_processor_v2", "0003_mark_deprecated_adapters"), + ] + + operations = [ + migrations.AddField( + model_name="adapterinstance", + name="co_owners", + field=models.ManyToManyField( + blank=True, + help_text="Users with full ownership privileges", + related_name="co_owned_adapters", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/adapter_processor_v2/migrations/0005_backfill_creator_to_co_owners.py b/backend/adapter_processor_v2/migrations/0005_backfill_creator_to_co_owners.py new file mode 100644 index 0000000000..f65b49eac9 --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0005_backfill_creator_to_co_owners.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def backfill_creator_to_co_owners(apps, schema_editor): + adapter_instance_model = apps.get_model("adapter_processor_v2", "AdapterInstance") + for adapter in adapter_instance_model.objects.filter(created_by__isnull=False): + if not adapter.co_owners.filter(id=adapter.created_by_id).exists(): + adapter.co_owners.add(adapter.created_by) + + +class Migration(migrations.Migration): + dependencies = [ + ("adapter_processor_v2", "0004_adapterinstance_co_owners"), + ] + + operations = [ + migrations.RunPython( + backfill_creator_to_co_owners, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 61a3fe9b0e..4110ba5f66 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -37,7 +37,7 @@ def for_user(self, user: User) -> QuerySet[Any]: return ( self.get_queryset() .filter( - models.Q(created_by=user) + models.Q(co_owners=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) | models.Q(is_friction_less=True) @@ -131,6 +131,12 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel): # Introduced field to establish M2M relation between users and adapters. # This will introduce intermediary table which relates both the models. shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance") + co_owners = models.ManyToManyField( + User, + related_name="co_owned_adapters", + blank=True, + help_text="Users with full ownership privileges", + ) description = models.TextField(blank=True, null=True, default=None) objects = AdapterInstanceModelManager() diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 1d931f1266..97594b54f7 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -4,6 +4,7 @@ from account_v2.serializer import UserSerializer from cryptography.fernet import Fernet from django.conf import settings +from permissions.co_owner_serializers import CoOwnerRepresentationMixin from rest_framework import serializers from rest_framework.serializers import ModelSerializer @@ -126,7 +127,7 @@ def get_context_window_size(self, obj: AdapterInstance) -> int: return obj.get_context_window_size() -class AdapterListSerializer(BaseAdapterSerializer): +class AdapterListSerializer(CoOwnerRepresentationMixin, BaseAdapterSerializer): """Inherits BaseAdapterSerializer. Used for listing adapters @@ -175,10 +176,10 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: if model: rep["model"] = model + request = self.context.get("request") + self.add_co_owner_fields(instance, rep, request) if instance.is_friction_less: rep["created_by_email"] = "Unstract" - else: - rep["created_by_email"] = instance.created_by.email return rep @@ -190,6 +191,7 @@ class SharedUserListSerializer(BaseAdapterSerializer): """ shared_users = UserSerializer(many=True) + co_owners = UserSerializer(many=True, read_only=True) created_by = UserSerializer() class Meta(BaseAdapterSerializer.Meta): @@ -200,6 +202,7 @@ class Meta(BaseAdapterSerializer.Meta): "adapter_name", "adapter_type", "created_by", + "co_owners", "shared_users", "shared_to_org", ) # type: ignore diff --git a/backend/adapter_processor_v2/urls.py b/backend/adapter_processor_v2/urls.py index 8e12fb7200..6629ba412f 100644 --- a/backend/adapter_processor_v2/urls.py +++ b/backend/adapter_processor_v2/urls.py @@ -25,6 +25,8 @@ adapter_users = AdapterInstanceViewSet.as_view({"get": "list_of_shared_users"}) adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"}) +adapter_add_owner = AdapterInstanceViewSet.as_view({"post": "add_co_owner"}) +adapter_remove_owner = AdapterInstanceViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( [ path("adapter_schema/", adapter_schema, name="get_adapter_schema"), @@ -39,5 +41,15 @@ adapter_users, name="adapter-users", ), + path( + "adapter//owners/", + adapter_add_owner, + name="adapter-add-owner", + ), + path( + "adapter//owners//", + adapter_remove_owner, + name="adapter-remove-owner", + ), ] ) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 3e59d12594..2450eeadb5 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.co_owner_views import CoOwnerManagementMixin from permissions.permission import ( IsFrictionLessAdapter, IsFrictionLessAdapterDelete, @@ -135,8 +136,26 @@ def test(self, request: Request) -> Response: ) -class AdapterInstanceViewSet(ModelViewSet): +class AdapterInstanceViewSet(CoOwnerManagementMixin, ModelViewSet): serializer_class = AdapterInstanceSerializer + notification_resource_name_field = "adapter_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + try: + from plugins.notification.constants import ResourceType + except ImportError: + logger.debug( + "Notification plugin not available, skipping resource type lookup" + ) + return None + + adapter_type_to_resource = { + "LLM": ResourceType.LLM.value, + "EMBEDDING": ResourceType.EMBEDDING.value, + "VECTOR_DB": ResourceType.VECTOR_DB.value, + "X2TEXT": ResourceType.X2TEXT.value, + } + return adapter_type_to_resource.get(resource.adapter_type) def get_permissions(self) -> list[Any]: if self.action in ["update", "retrieve"]: @@ -148,6 +167,9 @@ def get_permissions(self) -> list[Any]: elif self.action in ["list_of_shared_users", "adapter_info"]: return [IsOwnerOrSharedUserOrSharedToOrg()] + elif self.action in ["add_co_owner", "remove_co_owner"]: + return [IsOwner()] + # Hack for friction-less onboarding # User cant view/update metadata but can delete/share etc return [IsOwner()] @@ -162,7 +184,7 @@ def get_queryset(self) -> QuerySet | None: ) else: queryset = AdapterInstance.objects.for_user(self.request.user) - return queryset + return queryset.prefetch_related("co_owners") def get_serializer_class( self, diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 47d0aac34d..b0fb6921e1 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -6,6 +6,7 @@ from configuration.models import Configuration from django.db.models import F, OuterRef, QuerySet, Subquery from django.http import HttpResponse +from permissions.co_owner_views import CoOwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from plugins import get_plugin from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry @@ -17,7 +18,6 @@ from tool_instance_v2.models import ToolInstance from utils.enums import CeleryTaskState from utils.hubspot_notify import notify_hubspot_event -from utils.pagination import CustomPagination from workflow_manager.workflow_v2.dto import ExecutionResponse from workflow_manager.workflow_v2.models.execution import WorkflowExecution @@ -245,11 +245,22 @@ def get( ) -class APIDeploymentViewSet(viewsets.ModelViewSet): - pagination_class = CustomPagination +class APIDeploymentViewSet(CoOwnerManagementMixin, viewsets.ModelViewSet): + notification_resource_name_field = "display_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + from plugins.notification.constants import ResourceType + + return ResourceType.API_DEPLOYMENT.value # type: ignore 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()] @@ -277,7 +288,7 @@ def get_queryset(self) -> QuerySet | None: if search: queryset = queryset.filter(display_name__icontains=search) - return queryset + return queryset.prefetch_related("co_owners") def get_serializer_class(self) -> serializers.Serializer: if self.action in ["list"]: @@ -345,7 +356,9 @@ def by_prompt_studio_tool(self, request: Request) -> Response: workflow_id__in=workflow_ids, created_by=request.user ) - serializer = APIDeploymentListSerializer(deployments, many=True) + serializer = APIDeploymentListSerializer( + deployments, many=True, context={"request": request} + ) return Response(serializer.data, status=status.HTTP_200_OK) except PromptStudioRegistry.DoesNotExist: diff --git a/backend/api_v2/migrations/0004_apideployment_co_owners.py b/backend/api_v2/migrations/0004_apideployment_co_owners.py new file mode 100644 index 0000000000..587e4f4394 --- /dev/null +++ b/backend/api_v2/migrations/0004_apideployment_co_owners.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.1 on 2026-02-17 08:24 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api_v2", "0003_add_organization_rate_limit"), + ] + + operations = [ + migrations.AddField( + model_name="apideployment", + name="co_owners", + field=models.ManyToManyField( + blank=True, + help_text="Users with full ownership privileges", + related_name="co_owned_api_deployments", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/api_v2/migrations/0005_backfill_creator_to_co_owners.py b/backend/api_v2/migrations/0005_backfill_creator_to_co_owners.py new file mode 100644 index 0000000000..5320ea3d7c --- /dev/null +++ b/backend/api_v2/migrations/0005_backfill_creator_to_co_owners.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def backfill_creator_to_co_owners(apps, schema_editor): + api_deployment_model = apps.get_model("api_v2", "APIDeployment") + for deployment in api_deployment_model.objects.filter(created_by__isnull=False): + if not deployment.co_owners.filter(id=deployment.created_by_id).exists(): + deployment.co_owners.add(deployment.created_by) + + +class Migration(migrations.Migration): + dependencies = [ + ("api_v2", "0004_apideployment_co_owners"), + ] + + operations = [ + migrations.RunPython( + backfill_creator_to_co_owners, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index 437015c7f7..d262c0017c 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -27,14 +27,14 @@ class APIDeploymentModelManager(DefaultOrganizationManagerMixin, models.Manager): def for_user(self, user): """Filter API deployments that the user can access: - - API deployments created by the user + - API deployments co-owned by the user - API deployments shared with the user - API deployments shared with the entire organization """ from django.db.models import Q return self.filter( - Q(created_by=user) # Owned by user + Q(co_owners=user) # Co-owned by user | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization ).distinct() @@ -96,6 +96,12 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel): shared_users = models.ManyToManyField( User, related_name="shared_api_deployments", blank=True ) + co_owners = models.ManyToManyField( + User, + related_name="co_owned_api_deployments", + blank=True, + help_text="Users with full ownership privileges", + ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this API deployment is shared with the entire organization", diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 5a2068fd93..5e9134ebbc 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -6,6 +6,10 @@ from django.apps import apps from django.core.validators import RegexValidator +from permissions.co_owner_serializers import ( + CoOwnerRepresentationMixin, + SharedUserListMixin, +) from pipeline_v2.models import Pipeline from prompt_studio.prompt_profile_manager_v2.models import ProfileManager from rest_framework.serializers import ( @@ -422,9 +426,11 @@ def validate_execution_id(self, value): return str(uuid_obj) -class APIDeploymentListSerializer(ModelSerializer): +class APIDeploymentListSerializer(CoOwnerRepresentationMixin, ModelSerializer): workflow_name = CharField(source="workflow.workflow_name", read_only=True) created_by_email = SerializerMethodField() + co_owners_count = SerializerMethodField() + is_owner = SerializerMethodField() last_5_run_statuses = SerializerMethodField() run_count = SerializerMethodField() last_run_time = SerializerMethodField() @@ -442,15 +448,31 @@ class Meta: "api_name", "created_by", "created_by_email", + "co_owners_count", + "is_owner", "last_5_run_statuses", "run_count", "last_run_time", ] def get_created_by_email(self, obj): - """Get the email of the creator.""" + """Get the email of the primary owner (first co-owner).""" + first_co_owner = obj.co_owners.first() + if first_co_owner: + return first_co_owner.email return obj.created_by.email if obj.created_by else None + def get_co_owners_count(self, obj): + """Get the number of co-owners.""" + return obj.co_owners.count() + + def get_is_owner(self, obj): + """Check if the current user is a co-owner.""" + request = self.context.get("request") + if request and hasattr(request, "user"): + return obj.co_owners.filter(pk=request.user.pk).exists() + return False + def get_run_count(self, instance) -> int: """Get total execution count for this API deployment.""" return WorkflowExecution.objects.filter(pipeline_id=instance.id).count() @@ -501,22 +523,20 @@ class APIExecutionResponseSerializer(Serializer): result = JSONField() -class SharedUserListSerializer(ModelSerializer): +class SharedUserListSerializer(SharedUserListMixin, ModelSerializer): """Serializer for returning API deployment with shared user details.""" shared_users = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: model = APIDeployment - fields = ["id", "display_name", "shared_users", "shared_to_org", "created_by"] - - def get_shared_users(self, obj): - """Return list of shared users with id and email.""" - return [{"id": user.id, "email": user.email} for user in obj.shared_users.all()] - - def get_created_by(self, obj): - """Return creator details.""" - if obj.created_by: - return {"id": obj.created_by.id, "email": obj.created_by.email} - return None + fields = [ + "id", + "display_name", + "shared_users", + "co_owners", + "shared_to_org", + "created_by", + ] diff --git a/backend/api_v2/urls.py b/backend/api_v2/urls.py index 1e275ef9ea..a886fee369 100644 --- a/backend/api_v2/urls.py +++ b/backend/api_v2/urls.py @@ -33,6 +33,8 @@ "get": APIDeploymentViewSet.list_of_shared_users.__name__, } ) +deployment_add_owner = APIDeploymentViewSet.as_view({"post": "add_co_owner"}) +deployment_remove_owner = APIDeploymentViewSet.as_view({"delete": "remove_co_owner"}) execute = DeploymentExecution.as_view() @@ -63,6 +65,16 @@ list_shared_users, name="api_deployment_list_shared_users", ), + path( + "deployment//owners/", + deployment_add_owner, + name="api_deployment_add_owner", + ), + path( + "deployment//owners//", + deployment_remove_owner, + name="api_deployment_remove_owner", + ), path( "deployment/by-prompt-studio-tool/", by_prompt_studio_tool, diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py index 7cb4295300..7e0489e8fd 100644 --- a/backend/backend/serializers.py +++ b/backend/backend/serializers.py @@ -14,7 +14,10 @@ def create(self, validated_data: dict[str, Any]) -> Any: validated_data[RequestKey.MODIFIED_BY] = self.context.get( RequestKey.REQUEST ).user - return super().create(validated_data) + instance = super().create(validated_data) + if hasattr(instance, "co_owners") and instance.created_by: + instance.co_owners.add(instance.created_by) + return instance def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: if self.context.get(RequestKey.REQUEST): diff --git a/backend/connector_v2/migrations/0006_connectorinstance_co_owners.py b/backend/connector_v2/migrations/0006_connectorinstance_co_owners.py new file mode 100644 index 0000000000..47fdff3265 --- /dev/null +++ b/backend/connector_v2/migrations/0006_connectorinstance_co_owners.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.1 on 2026-02-23 14:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("connector_v2", "0005_fix_unintended_connector_sharing"), + ] + + operations = [ + migrations.AddField( + model_name="connectorinstance", + name="co_owners", + field=models.ManyToManyField( + blank=True, + help_text="Users with full ownership privileges", + related_name="co_owned_connectors", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/connector_v2/migrations/0007_backfill_creator_to_co_owners.py b/backend/connector_v2/migrations/0007_backfill_creator_to_co_owners.py new file mode 100644 index 0000000000..cefa7fddad --- /dev/null +++ b/backend/connector_v2/migrations/0007_backfill_creator_to_co_owners.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def backfill_creator_to_co_owners(apps, schema_editor): + connector_instance_model = apps.get_model("connector_v2", "ConnectorInstance") + for connector in connector_instance_model.objects.filter(created_by__isnull=False): + if not connector.co_owners.filter(id=connector.created_by_id).exists(): + connector.co_owners.add(connector.created_by) + + +class Migration(migrations.Migration): + dependencies = [ + ("connector_v2", "0006_connectorinstance_co_owners"), + ] + + operations = [ + migrations.RunPython( + backfill_creator_to_co_owners, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index bcd7bb5973..9a6b77f50e 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -30,7 +30,7 @@ def for_user(self, user: User) -> models.QuerySet: return ( self.get_queryset() .filter( - models.Q(created_by=user) + models.Q(co_owners=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) ) @@ -97,6 +97,12 @@ class ConnectorMode(models.TextChoices): shared_users = models.ManyToManyField( User, related_name="shared_connectors", blank=True ) + co_owners = models.ManyToManyField( + User, + related_name="co_owned_connectors", + blank=True, + help_text="Users with full ownership privileges", + ) objects = ConnectorInstanceModelManager() diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index 45d5c07562..f7953afc1b 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -7,6 +7,8 @@ from connector_processor.connector_processor import ConnectorProcessor from connector_processor.constants import ConnectorKeys from connector_processor.exceptions import OAuthTimeOut +from permissions.co_owner_serializers import CoOwnerRepresentationMixin +from rest_framework import serializers from rest_framework.serializers import CharField, SerializerMethodField from utils.fields import EncryptedBinaryFieldSerializer @@ -19,7 +21,7 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceSerializer(AuditSerializer): +class ConnectorInstanceSerializer(CoOwnerRepresentationMixin, AuditSerializer): connector_metadata = EncryptedBinaryFieldSerializer(required=False, allow_null=True) icon = SerializerMethodField() created_by_email = CharField(source="created_by.email", read_only=True) @@ -87,4 +89,47 @@ def to_representation(self, instance: ConnectorInstance) -> dict[str, str]: # Remove sensitive connector auth from the response rep.pop(CIKey.CONNECTOR_AUTH) + # Co-owner information + request = self.context.get("request") + self.add_co_owner_fields(instance, rep, request) + return rep + + +class SharedUserListSerializer(serializers.ModelSerializer): + """Used for listing connector users.""" + + shared_users = SerializerMethodField() + co_owners = SerializerMethodField() + created_by = SerializerMethodField() + created_by_email = SerializerMethodField() + + class Meta: + model = ConnectorInstance + fields = ( + "id", + "connector_name", + "created_by", + "created_by_email", + "co_owners", + "shared_users", + "shared_to_org", + ) + + def get_shared_users(self, obj): + """Get list of shared users with id and email.""" + return [{"id": u.id, "email": u.email} for u in obj.shared_users.all()] + + def get_co_owners(self, obj): + """Get list of co-owners with id and email.""" + return [{"id": u.id, "email": u.email} for u in obj.co_owners.all()] + + def get_created_by(self, obj): + """Get creator details.""" + if obj.created_by: + return {"id": obj.created_by.id, "email": obj.created_by.email} + return None + + def get_created_by_email(self, obj): + """Get the creator's email.""" + return obj.created_by.email if obj.created_by else None diff --git a/backend/connector_v2/urls.py b/backend/connector_v2/urls.py index 4240335289..b2d77ed79e 100644 --- a/backend/connector_v2/urls.py +++ b/backend/connector_v2/urls.py @@ -12,10 +12,28 @@ "delete": "destroy", } ) +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( [ path("connector/", connector_list, name="connector-list"), path("connector//", connector_detail, name="connector-detail"), + 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 09a33be983..5301a539f9 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -8,9 +8,11 @@ from connector_processor.exceptions import OAuthTimeOut from django.db import IntegrityError from django.db.models import ProtectedError, QuerySet +from permissions.co_owner_views import CoOwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg 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 @@ -23,7 +25,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: @@ -33,14 +35,23 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceViewSet(viewsets.ModelViewSet): +class ConnectorInstanceViewSet(CoOwnerManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning serializer_class = ConnectorInstanceSerializer + notification_resource_name_field = "connector_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + from plugins.notification.constants import ResourceType + + return ResourceType.CONNECTOR.value # type: ignore def get_permissions(self) -> list[Any]: if self.action in ["update", "destroy", "partial_update"]: return [IsOwner()] + if self.action in ["add_co_owner", "remove_co_owner"]: + return [IsOwner()] + return [IsOwnerOrSharedUserOrSharedToOrg()] def get_queryset(self) -> QuerySet | None: @@ -73,7 +84,7 @@ def get_queryset(self) -> QuerySet | None: ) queryset = queryset.none() - return queryset + return queryset.prefetch_related("co_owners") def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None: """Gets connector metadata for the ConnectorInstance. @@ -236,3 +247,9 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons ) return response + + @action(detail=True, methods=["get"]) + def list_of_shared_users(self, request: Request, pk: Any = None) -> Response: + connector = self.get_object() + serialized_instances = SharedUserListSerializer(connector).data + return Response(serialized_instances) diff --git a/backend/permissions/co_owner_serializers.py b/backend/permissions/co_owner_serializers.py new file mode 100644 index 0000000000..223c24dd6c --- /dev/null +++ b/backend/permissions/co_owner_serializers.py @@ -0,0 +1,107 @@ +"""Shared serializers for co-owner management across resource types.""" + +from typing import Any + +from account_v2.models import User +from django.db import models, transaction +from rest_framework import serializers +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + + +class SharedUserListMixin: + """Mixin providing shared_users, co_owners, and created_by serializer methods.""" + + def get_shared_users(self, obj: models.Model) -> list[dict[str, Any]]: + """Return list of shared users with id and email.""" + return [{"id": user.id, "email": user.email} for user in obj.shared_users.all()] + + def get_co_owners(self, obj: models.Model) -> list[dict[str, Any]]: + """Return list of co-owners with id and email.""" + return [{"id": user.id, "email": user.email} for user in obj.co_owners.all()] + + def get_created_by(self, obj: models.Model) -> dict[str, Any] | None: + """Return creator details.""" + if obj.created_by: + return {"id": obj.created_by.id, "email": obj.created_by.email} + return None + + +class CoOwnerRepresentationMixin: + """Mixin to add co_owners_count, is_owner, created_by_email fields.""" + + def add_co_owner_fields( + self, + instance: models.Model, + representation: dict[str, Any], + request: Any = None, + ) -> dict[str, Any]: + created_by_email = instance.created_by.email if instance.created_by else None + representation["created_by_email"] = created_by_email + co_owners = instance.co_owners.all() + representation["co_owners_count"] = len(co_owners) + representation["is_owner"] = ( + any(u.pk == request.user.pk for u in co_owners) + if request and hasattr(request, "user") + else False + ) + return representation + + +class AddCoOwnerSerializer(serializers.Serializer): # type: ignore[misc] + """Serializer for adding a co-owner to a resource.""" + + user_id = serializers.IntegerField() + + def validate_user_id(self, value: int) -> int: + """Validate user exists in same organization and is not already an owner.""" + resource: models.Model = self.context["resource"] + organization = UserContext.get_organization() + + # Check user exists in organization + if not OrganizationMember.objects.filter( + user__id=value, organization=organization + ).exists(): + raise serializers.ValidationError("User not found in your organization.") + + # Check user is not already a co-owner (creator is always in co_owners) + if resource.co_owners.filter(id=value).exists(): + raise serializers.ValidationError("User is already an owner.") + + return value + + def save(self, **kwargs: Any) -> models.Model: + """Add user as co-owner.""" + resource: models.Model = self.context["resource"] + user_id = self.validated_data["user_id"] + resource.co_owners.add(user_id) + return resource + + +class RemoveCoOwnerSerializer(serializers.Serializer): # type: ignore[misc] + """Serializer for validating co-owner removal.""" + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + """Validate the user is actually a co-owner.""" + resource: models.Model = self.context["resource"] + user_to_remove: User = self.context["user_to_remove"] + + # co_owners is the single source of truth (creator is always in it) + if not resource.co_owners.filter(id=user_to_remove.id).exists(): + raise serializers.ValidationError("User is not an owner of this resource.") + + return attrs + + def save(self, **kwargs: Any) -> models.Model: + """Remove user as owner atomically to prevent race conditions.""" + resource: models.Model = self.context["resource"] + user_to_remove: User = self.context["user_to_remove"] + with transaction.atomic(): + locked = type(resource).objects.select_for_update().get(pk=resource.pk) + if locked.co_owners.count() <= 1: + raise serializers.ValidationError( + "Cannot remove the last owner. " + "Add another owner before removing this one." + ) + locked.co_owners.remove(user_to_remove) + return locked diff --git a/backend/permissions/co_owner_views.py b/backend/permissions/co_owner_views.py new file mode 100644 index 0000000000..047cbee798 --- /dev/null +++ b/backend/permissions/co_owner_views.py @@ -0,0 +1,188 @@ +"""Shared mixin for co-owner management actions across resource types.""" + +import logging +from typing import Any + +from account_v2.models import User +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from permissions.co_owner_serializers import ( + AddCoOwnerSerializer, + RemoveCoOwnerSerializer, +) + +logger = logging.getLogger(__name__) + + +class CoOwnerManagementMixin: + """Mixin that adds co-owner management endpoints to a ViewSet. + + Adds: + - POST /owners/ -> add_co_owner + - DELETE /owners// -> remove_co_owner + + Subclasses can opt in to co-owner email notifications by setting: + notification_resource_name_field = "" + and overriding: + get_notification_resource_type(resource) -> str | None + """ + + notification_resource_name_field: str | None = None + + def get_notification_resource_type(self, _resource: Any) -> str | None: + """Return the ResourceType value for notifications, or None to skip.""" + return None + + def _get_notification_context(self, resource: Any) -> tuple[str, str] | None: + """Return (resource_type, resource_name) or None if not opted in.""" + resource_type = self.get_notification_resource_type(resource) + if not resource_type: + return None + + name_field = self.notification_resource_name_field + if not name_field: + return None + + resource_name: str = getattr(resource, name_field, None) or "" + return resource_type, resource_name + + def _send_co_owner_added_notification( + self, resource: Any, user: User, request: Request + ) -> None: + """Send an email notification to a newly added co-owner. + + Does nothing when the notification plugin is not installed (OSS) + or when the ViewSet has not opted in. + """ + ctx = self._get_notification_context(resource) + if not ctx: + return + + resource_type, resource_name = ctx + + try: + from plugins.notification.co_owner_notification import ( + CoOwnerNotificationService, + ) + + service = CoOwnerNotificationService() + service.send_co_owner_added_notification( + resource_type=resource_type, + resource_name=resource_name, + resource_id=str(resource.pk), + added_by=request.user, + added_users=[user], + resource_instance=resource, + ) + except ImportError: + logger.debug( + "Notification plugin not available, skipping co-owner notification" + ) + except Exception: + logger.exception( + "Failed to send co-owner added notification for %s %s", + resource.__class__.__name__, + resource.pk, + ) + + def _send_co_owner_revoked_notification( + self, resource: Any, user: User, request: Request + ) -> None: + """Send an email notification to a removed co-owner. + + Does nothing when the notification plugin is not installed (OSS) + or when the ViewSet has not opted in. + """ + ctx = self._get_notification_context(resource) + if not ctx: + return + + resource_type, resource_name = ctx + + try: + from plugins.notification.co_owner_notification import ( + CoOwnerNotificationService, + ) + + service = CoOwnerNotificationService() + service.send_co_owner_revoked_notification( + resource_type=resource_type, + resource_name=resource_name, + resource_id=str(resource.pk), + removed_by=request.user, + removed_users=[user], + resource_instance=resource, + ) + except ImportError: + logger.debug( + "Notification plugin not available, skipping co-owner notification" + ) + except Exception: + logger.exception( + "Failed to send co-owner revoked notification for %s %s", + resource.__class__.__name__, + resource.pk, + ) + + def add_co_owner(self, request: Request, pk: Any = None) -> Response: + """Add a co-owner to the resource.""" + _ = pk # Used by DRF router; object is fetched via get_object() + resource = self.get_object() # type: ignore[attr-defined] + + serializer = AddCoOwnerSerializer( + data=request.data, + context={"request": request, "resource": resource}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + user = User.objects.get(id=serializer.validated_data["user_id"]) + logger.info( + "Co-owner user_id=%s added to %s %s by user_id=%s", + user.id, + resource.__class__.__name__, + resource.pk, + request.user.id, + ) + + self._send_co_owner_added_notification(resource, user, request) + + co_owners = [{"id": u.id, "email": u.email} for u in resource.co_owners.all()] + return Response( + {"id": str(resource.pk), "co_owners": co_owners}, + status=status.HTTP_200_OK, + ) + + def remove_co_owner( + self, request: Request, pk: Any = None, user_id: Any = None + ) -> Response: + """Remove a co-owner from the resource.""" + _ = pk # Used by DRF router; object is fetched via get_object() + resource = self.get_object() # type: ignore[attr-defined] + user_to_remove = get_object_or_404(User, id=user_id) + + serializer = RemoveCoOwnerSerializer( + data={}, + context={ + "request": request, + "resource": resource, + "user_to_remove": user_to_remove, + }, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + logger.info( + "Owner user_id=%s removed from %s %s by user_id=%s", + user_to_remove.id, + resource.__class__.__name__, + resource.pk, + request.user.id, + ) + + self._send_co_owner_revoked_notification(resource, user_to_remove, request) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 5e2e45c358..3f565ca6bf 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -8,10 +8,21 @@ class IsOwner(permissions.BasePermission): - """Custom permission to only allow owners of an object.""" + """Custom permission to only allow owners of an object. + + Owners include both the original creator (created_by) + and any co-owners. + """ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: - return True if obj.created_by == request.user else False + if obj.created_by == request.user: + return True + if ( + hasattr(obj, "co_owners") + and obj.co_owners.filter(pk=request.user.pk).exists() + ): + return True + return False class IsOrganizationMember(permissions.BasePermission): @@ -26,14 +37,16 @@ class IsOwnerOrSharedUser(permissions.BasePermission): """Custom permission to only allow owners and shared users of an object.""" def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: - return ( - True - if ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() - ) - else False - ) + if obj.created_by == request.user: + return True + if ( + hasattr(obj, "co_owners") + and obj.co_owners.filter(pk=request.user.pk).exists() + ): + return True + if obj.shared_users.filter(pk=request.user.pk).exists(): + return True + return False class IsOwnerOrSharedUserOrSharedToOrg(permissions.BasePermission): @@ -42,15 +55,18 @@ class IsOwnerOrSharedUserOrSharedToOrg(permissions.BasePermission): """ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: - return ( - True - if ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() - or obj.shared_to_org - ) - else False - ) + if obj.created_by == request.user: + return True + if ( + hasattr(obj, "co_owners") + and obj.co_owners.filter(pk=request.user.pk).exists() + ): + return True + if obj.shared_users.filter(pk=request.user.pk).exists(): + return True + if obj.shared_to_org: + return True + return False class IsFrictionLessAdapter(permissions.BasePermission): @@ -64,7 +80,7 @@ def has_object_permission( if obj.is_friction_less: return False - return True if obj.created_by == request.user else False + return IsOwner().has_object_permission(request, view, obj) class IsFrictionLessAdapterDelete(permissions.BasePermission): @@ -78,4 +94,4 @@ def has_object_permission( if obj.is_friction_less: return True - return True if obj.created_by == request.user else False + return IsOwner().has_object_permission(request, view, obj) diff --git a/backend/permissions/tests/__init__.py b/backend/permissions/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/permissions/tests/conftest.py b/backend/permissions/tests/conftest.py new file mode 100644 index 0000000000..be15e00cf1 --- /dev/null +++ b/backend/permissions/tests/conftest.py @@ -0,0 +1,6 @@ +import os + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.dev") +django.setup() diff --git a/backend/permissions/tests/test_co_owners.py b/backend/permissions/tests/test_co_owners.py new file mode 100644 index 0000000000..4d56a1bced --- /dev/null +++ b/backend/permissions/tests/test_co_owners.py @@ -0,0 +1,513 @@ +"""Tests for co-owner management: permissions, serializers, and views.""" + +from unittest.mock import MagicMock, Mock, patch +from uuid import uuid4 + +from django.test import RequestFactory, TestCase +from permissions.co_owner_serializers import AddCoOwnerSerializer, RemoveCoOwnerSerializer +from permissions.co_owner_views import CoOwnerManagementMixin +from permissions.permission import ( + IsOwner, + IsOwnerOrSharedUser, + IsOwnerOrSharedUserOrSharedToOrg, +) + + +def make_filter_side_effect(target_pk: object): + """Return a filter side_effect that returns True only for the given pk.""" + + def filter_side_effect(**kwargs: object) -> Mock: + mock_qs = Mock() + mock_qs.exists.return_value = kwargs.get("pk") == target_pk + return mock_qs + + return filter_side_effect + + +def make_resource_mock(creator: Mock, co_owner_count: int = 1) -> Mock: + """Create a mock resource with a co_owners queryset.""" + resource = Mock() + resource.created_by = creator + co_owners_qs = MagicMock() + co_owners_qs.count.return_value = co_owner_count + co_owners_qs.filter.return_value.exists.return_value = False + resource.co_owners = co_owners_qs + return resource + + +class CoOwnerPermissionTestBase(TestCase): + """Base class for co-owner permission tests.""" + + def setUp(self) -> None: + self.factory = RequestFactory() + + self.creator = Mock() + self.creator.pk = uuid4() + + self.co_owner = Mock() + self.co_owner.pk = uuid4() + + self.other_user = Mock() + self.other_user.pk = uuid4() + + self.resource = Mock() + self.resource.created_by = self.creator + co_owners_qs = MagicMock() + co_owners_qs.filter.return_value.exists.return_value = False + self.resource.co_owners = co_owners_qs + + def _make_request(self, user: Mock) -> Mock: + request = self.factory.get("/fake/") + request.user = user + return request + + +class TestIsOwnerPermission(CoOwnerPermissionTestBase): + """Test the IsOwner permission class recognizes co-owners.""" + + def setUp(self) -> None: + super().setUp() + self.permission = IsOwner() + + def test_creator_has_permission(self) -> None: + request = self._make_request(self.creator) + self.assertTrue( + self.permission.has_object_permission(request, None, self.resource) + ) + + def test_co_owner_has_permission(self) -> None: + self.resource.co_owners.filter.side_effect = make_filter_side_effect( + self.co_owner.pk + ) + request = self._make_request(self.co_owner) + self.assertTrue( + self.permission.has_object_permission(request, None, self.resource) + ) + + def test_other_user_denied(self) -> None: + request = self._make_request(self.other_user) + self.assertFalse( + self.permission.has_object_permission(request, None, self.resource) + ) + + def test_object_without_co_owners_field(self) -> None: + """Permission works for objects that don't have co_owners.""" + resource = Mock(spec=["created_by"]) + resource.created_by = self.creator + + request = self._make_request(self.other_user) + self.assertFalse( + self.permission.has_object_permission(request, None, resource) + ) + + +class TestIsOwnerOrSharedUserPermission(CoOwnerPermissionTestBase): + """Test IsOwnerOrSharedUser recognizes co-owners.""" + + def setUp(self) -> None: + super().setUp() + self.permission = IsOwnerOrSharedUser() + + self.shared_user = Mock() + self.shared_user.pk = uuid4() + + shared_users_qs = MagicMock() + shared_users_qs.filter.return_value.exists.return_value = False + self.resource.shared_users = shared_users_qs + + def test_co_owner_has_permission(self) -> None: + self.resource.co_owners.filter.side_effect = make_filter_side_effect( + self.co_owner.pk + ) + request = self._make_request(self.co_owner) + self.assertTrue( + self.permission.has_object_permission(request, None, self.resource) + ) + + def test_shared_user_has_permission(self) -> None: + self.resource.shared_users.filter.side_effect = make_filter_side_effect( + self.shared_user.pk + ) + request = self._make_request(self.shared_user) + self.assertTrue( + self.permission.has_object_permission(request, None, self.resource) + ) + + def test_other_user_denied(self) -> None: + request = self._make_request(self.other_user) + self.assertFalse( + self.permission.has_object_permission(request, None, self.resource) + ) + + +class TestIsOwnerOrSharedUserOrSharedToOrgPermission(CoOwnerPermissionTestBase): + """Test IsOwnerOrSharedUserOrSharedToOrg recognizes co-owners.""" + + def setUp(self) -> None: + super().setUp() + self.permission = IsOwnerOrSharedUserOrSharedToOrg() + self.resource.shared_to_org = False + + shared_users_qs = MagicMock() + shared_users_qs.filter.return_value.exists.return_value = False + self.resource.shared_users = shared_users_qs + + def test_co_owner_has_permission(self) -> None: + self.resource.co_owners.filter.side_effect = make_filter_side_effect( + self.co_owner.pk + ) + request = self._make_request(self.co_owner) + self.assertTrue( + self.permission.has_object_permission(request, None, self.resource) + ) + + def test_shared_to_org_grants_access(self) -> None: + self.resource.shared_to_org = True + request = self._make_request(self.other_user) + self.assertTrue( + self.permission.has_object_permission(request, None, self.resource) + ) + + +class TestAddCoOwnerSerializer(TestCase): + """Test AddCoOwnerSerializer validation logic.""" + + @patch("permissions.co_owner_serializers.User.objects") + @patch("permissions.co_owner_serializers.OrganizationMember.objects") + @patch("permissions.co_owner_serializers.UserContext.get_organization") + def test_valid_add_co_owner( + self, mock_get_org: Mock, mock_org_member: Mock, mock_user_objects: Mock + ) -> None: + creator = Mock() + new_co_owner = Mock() + new_co_owner.id = 42 + + mock_get_org.return_value = Mock() + mock_org_member.filter.return_value.exists.return_value = True + mock_user_objects.get.return_value = new_co_owner + + resource = make_resource_mock(creator) + + serializer = AddCoOwnerSerializer( + data={"user_id": 42}, + context={"resource": resource}, + ) + self.assertTrue(serializer.is_valid()) + + @patch("permissions.co_owner_serializers.OrganizationMember.objects") + @patch("permissions.co_owner_serializers.UserContext.get_organization") + def test_user_not_in_organization( + self, mock_get_org: Mock, mock_org_member: Mock + ) -> None: + creator = Mock() + mock_get_org.return_value = Mock() + mock_org_member.filter.return_value.exists.return_value = False + + resource = make_resource_mock(creator) + + serializer = AddCoOwnerSerializer( + data={"user_id": 999}, + context={"resource": resource}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("user_id", serializer.errors) + + @patch("permissions.co_owner_serializers.User.objects") + @patch("permissions.co_owner_serializers.OrganizationMember.objects") + @patch("permissions.co_owner_serializers.UserContext.get_organization") + def test_cannot_add_creator_as_co_owner( + self, mock_get_org: Mock, mock_org_member: Mock, mock_user_objects: Mock + ) -> None: + creator = Mock() + creator.id = 1 + + mock_get_org.return_value = Mock() + mock_org_member.filter.return_value.exists.return_value = True + mock_user_objects.get.return_value = creator + + resource = make_resource_mock(creator) + # Creator is already in co_owners + resource.co_owners.filter.return_value.exists.return_value = True + + serializer = AddCoOwnerSerializer( + data={"user_id": 1}, + context={"resource": resource}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("user_id", serializer.errors) + + @patch("permissions.co_owner_serializers.User.objects") + @patch("permissions.co_owner_serializers.OrganizationMember.objects") + @patch("permissions.co_owner_serializers.UserContext.get_organization") + def test_cannot_add_existing_co_owner( + self, mock_get_org: Mock, mock_org_member: Mock, mock_user_objects: Mock + ) -> None: + creator = Mock() + existing_co_owner = Mock() + existing_co_owner.id = 99 + + mock_get_org.return_value = Mock() + mock_org_member.filter.return_value.exists.return_value = True + mock_user_objects.get.return_value = existing_co_owner + + resource = make_resource_mock(creator) + # Already a co-owner + resource.co_owners.filter.return_value.exists.return_value = True + + serializer = AddCoOwnerSerializer( + data={"user_id": 99}, + context={"resource": resource}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("user_id", serializer.errors) + + @patch("permissions.co_owner_serializers.User.objects") + @patch("permissions.co_owner_serializers.OrganizationMember.objects") + @patch("permissions.co_owner_serializers.UserContext.get_organization") + def test_save_adds_co_owner( + self, mock_get_org: Mock, mock_org_member: Mock, mock_user_objects: Mock + ) -> None: + creator = Mock() + new_user = Mock() + new_user.id = 77 + + mock_get_org.return_value = Mock() + mock_org_member.filter.return_value.exists.return_value = True + mock_user_objects.get.return_value = new_user + + resource = make_resource_mock(creator) + + serializer = AddCoOwnerSerializer( + data={"user_id": 77}, + context={"resource": resource}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + resource.co_owners.add.assert_called_once_with(new_user) + + +class TestRemoveCoOwnerSerializer(TestCase): + """Test RemoveCoOwnerSerializer validation and save logic.""" + + def test_cannot_remove_last_owner(self) -> None: + creator = Mock() + resource = make_resource_mock(creator, co_owner_count=1) + resource.co_owners.filter.return_value.exists.return_value = True + + serializer = RemoveCoOwnerSerializer( + data={}, + context={"resource": resource, "user_to_remove": creator}, + ) + self.assertFalse(serializer.is_valid()) + + def test_can_remove_co_owner_when_multiple_owners(self) -> None: + creator = Mock() + co_owner = Mock() + co_owner.id = uuid4() + + resource = make_resource_mock(creator, co_owner_count=2) + resource.co_owners.filter.return_value.exists.return_value = True + + serializer = RemoveCoOwnerSerializer( + data={}, + context={"resource": resource, "user_to_remove": co_owner}, + ) + self.assertTrue(serializer.is_valid()) + + def test_user_not_an_owner(self) -> None: + creator = Mock() + random_user = Mock() + random_user.id = uuid4() + + resource = make_resource_mock(creator, co_owner_count=1) + + serializer = RemoveCoOwnerSerializer( + data={}, + context={"resource": resource, "user_to_remove": random_user}, + ) + self.assertFalse(serializer.is_valid()) + + def test_save_removes_co_owner(self) -> None: + creator = Mock() + co_owner = Mock() + co_owner.id = uuid4() + + resource = make_resource_mock(creator, co_owner_count=2) + resource.co_owners.filter.return_value.exists.return_value = True + + serializer = RemoveCoOwnerSerializer( + data={}, + context={"resource": resource, "user_to_remove": co_owner}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + resource.co_owners.remove.assert_called_once_with(co_owner) + + def test_save_removes_creator_without_promotion(self) -> None: + """Removing creator just removes from co_owners, created_by is audit-only.""" + creator = Mock() + co_owner = Mock() + co_owner.id = uuid4() + + resource = make_resource_mock(creator, co_owner_count=2) + resource.co_owners.filter.return_value.exists.return_value = True + + serializer = RemoveCoOwnerSerializer( + data={}, + context={"resource": resource, "user_to_remove": creator}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + resource.co_owners.remove.assert_called_once_with(creator) + resource.save.assert_not_called() + + +class TestCoOwnerNotification(TestCase): + """Tests for co-owner addition and revocation email notifications.""" + + def _make_mixin( + self, resource_name_field: str | None = None, resource_type: str | None = None + ) -> CoOwnerManagementMixin: + """Create a mixin instance with optional notification opt-in.""" + mixin = CoOwnerManagementMixin() + if resource_name_field is not None: + mixin.notification_resource_name_field = resource_name_field + if resource_type is not None: + mixin.get_notification_resource_type = lambda self_unused: resource_type # type: ignore[assignment] + return mixin + + def _make_resource(self, name_value: str = "My Resource") -> Mock: + resource = Mock() + resource.pk = uuid4() + resource.__class__ = type("FakeModel", (), {}) + resource.workflow_name = name_value + return resource + + def _make_request(self) -> Mock: + request = Mock() + request.user = Mock() + request.user.email = "actor@example.com" + return request + + # --- Addition notification tests --- + + @patch( + "plugins.notification.co_owner_notification.CoOwnerNotificationService", + ) + def test_add_co_owner_sends_notification( + self, mock_service_cls: Mock + ) -> None: + """Notification is sent when plugin is available and ViewSet opts in.""" + mock_instance = mock_service_cls.return_value + + mixin = self._make_mixin( + resource_name_field="workflow_name", + resource_type="workflow", + ) + resource = self._make_resource("Test Workflow") + user = Mock() + user.email = "newowner@example.com" + request = self._make_request() + + mixin._send_co_owner_added_notification(resource, user, request) + + mock_instance.send_co_owner_added_notification.assert_called_once_with( + resource_type="workflow", + resource_name="Test Workflow", + resource_id=str(resource.pk), + added_by=request.user, + added_users=[user], + resource_instance=resource, + ) + + @patch( + "plugins.notification.co_owner_notification.CoOwnerNotificationService", + side_effect=ImportError("no plugin"), + ) + def test_add_co_owner_notification_failure_does_not_break( + self, mock_service_cls: Mock + ) -> None: + """Notification errors are logged but do not propagate.""" + mixin = self._make_mixin( + resource_name_field="workflow_name", + resource_type="workflow", + ) + resource = self._make_resource() + user = Mock() + request = self._make_request() + + # Should not raise + mixin._send_co_owner_added_notification(resource, user, request) + + def test_add_co_owner_no_notification_when_not_opted_in(self) -> None: + """No notification when ViewSet does not opt in (returns None).""" + mixin = CoOwnerManagementMixin() + resource = self._make_resource() + user = Mock() + request = self._make_request() + + # Should not raise and should not attempt to import service + mixin._send_co_owner_added_notification(resource, user, request) + + # --- Revocation notification tests --- + + @patch( + "plugins.notification.co_owner_notification.CoOwnerNotificationService", + ) + def test_revoke_co_owner_sends_notification( + self, mock_service_cls: Mock + ) -> None: + """Revoke notification is sent when plugin is available and ViewSet opts in.""" + mock_instance = mock_service_cls.return_value + + mixin = self._make_mixin( + resource_name_field="workflow_name", + resource_type="workflow", + ) + resource = self._make_resource("Test Workflow") + user = Mock() + user.email = "removedowner@example.com" + request = self._make_request() + + mixin._send_co_owner_revoked_notification(resource, user, request) + + mock_instance.send_co_owner_revoked_notification.assert_called_once_with( + resource_type="workflow", + resource_name="Test Workflow", + resource_id=str(resource.pk), + removed_by=request.user, + removed_users=[user], + resource_instance=resource, + ) + + @patch( + "plugins.notification.co_owner_notification.CoOwnerNotificationService", + side_effect=ImportError("no plugin"), + ) + def test_revoke_co_owner_notification_failure_does_not_break( + self, mock_service_cls: Mock + ) -> None: + """Revoke notification errors are logged but do not propagate.""" + mixin = self._make_mixin( + resource_name_field="workflow_name", + resource_type="workflow", + ) + resource = self._make_resource() + user = Mock() + request = self._make_request() + + # Should not raise + mixin._send_co_owner_revoked_notification(resource, user, request) + + def test_revoke_co_owner_no_notification_when_not_opted_in(self) -> None: + """No revoke notification when ViewSet does not opt in.""" + mixin = CoOwnerManagementMixin() + resource = self._make_resource() + user = Mock() + request = self._make_request() + + # Should not raise and should not attempt to import service + mixin._send_co_owner_revoked_notification(resource, user, request) diff --git a/backend/pipeline_v2/migrations/0004_pipeline_co_owners.py b/backend/pipeline_v2/migrations/0004_pipeline_co_owners.py new file mode 100644 index 0000000000..477bbb2361 --- /dev/null +++ b/backend/pipeline_v2/migrations/0004_pipeline_co_owners.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.1 on 2026-02-17 08:24 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("pipeline_v2", "0003_add_sharing_fields_to_pipeline"), + ] + + operations = [ + migrations.AddField( + model_name="pipeline", + name="co_owners", + field=models.ManyToManyField( + blank=True, + help_text="Users with full ownership privileges", + related_name="co_owned_pipelines", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/pipeline_v2/migrations/0005_backfill_creator_to_co_owners.py b/backend/pipeline_v2/migrations/0005_backfill_creator_to_co_owners.py new file mode 100644 index 0000000000..4c3a1741a9 --- /dev/null +++ b/backend/pipeline_v2/migrations/0005_backfill_creator_to_co_owners.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def backfill_creator_to_co_owners(apps, schema_editor): + pipeline_model = apps.get_model("pipeline_v2", "Pipeline") + for pipeline in pipeline_model.objects.filter(created_by__isnull=False): + if not pipeline.co_owners.filter(id=pipeline.created_by_id).exists(): + pipeline.co_owners.add(pipeline.created_by) + + +class Migration(migrations.Migration): + dependencies = [ + ("pipeline_v2", "0004_pipeline_co_owners"), + ] + + operations = [ + migrations.RunPython( + backfill_creator_to_co_owners, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index cdd85405f5..c786db4129 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -21,12 +21,12 @@ class PipelineModelManager(DefaultOrganizationManagerMixin, models.Manager): def for_user(self, user): """Filter pipelines that the user can access: - - Pipelines created by the user + - Pipelines co-owned by the user - Pipelines shared with the user - Pipelines shared with the entire organization """ return self.filter( - Q(created_by=user) # Owned by user + Q(co_owners=user) # Co-owned by user | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization ).distinct() @@ -109,6 +109,12 @@ class PipelineStatus(models.TextChoices): blank=True, db_comment="Users with whom this pipeline is shared", ) + co_owners = models.ManyToManyField( + User, + related_name="co_owned_pipelines", + blank=True, + help_text="Users with full ownership privileges", + ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this pipeline is shared with the entire organization", diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index f887042b9a..3308c5e501 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -6,6 +6,7 @@ from croniter import croniter from django.conf import settings from django.utils import timezone +from permissions.co_owner_serializers import CoOwnerRepresentationMixin from pipeline_v2.constants import PipelineConstants as PC from pipeline_v2.constants import PipelineKey as PK from pipeline_v2.constants import PipelineScheduling @@ -25,7 +26,9 @@ DEPLOYMENT_ENDPOINT = settings.API_DEPLOYMENT_PATH_PREFIX + "/pipeline" -class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): +class PipelineSerializer( + CoOwnerRepresentationMixin, IntegrityErrorMixin, AuditSerializer +): api_endpoint = SerializerMethodField() created_by_email = SerializerMethodField() last_5_run_statuses = SerializerMethodField() @@ -202,7 +205,10 @@ def get_api_endpoint(self, instance: Pipeline): return instance.api_endpoint def get_created_by_email(self, obj): - """Get the creator's email address.""" + """Get the email of the primary owner (first co-owner).""" + first_co_owner = obj.co_owners.first() + if first_co_owner: + return first_co_owner.email return obj.created_by.email if obj.created_by else None def get_last_5_run_statuses(self, instance: Pipeline) -> list[dict]: @@ -318,4 +324,7 @@ def to_representation(self, instance: Pipeline) -> OrderedDict[str, Any]: connectors=connectors, ) + request = self.context.get("request") + self.add_co_owner_fields(instance, repr, request) + return repr diff --git a/backend/pipeline_v2/serializers/sharing.py b/backend/pipeline_v2/serializers/sharing.py index 1536745fb8..c2f3a5e858 100644 --- a/backend/pipeline_v2/serializers/sharing.py +++ b/backend/pipeline_v2/serializers/sharing.py @@ -1,6 +1,5 @@ """Serializers for pipeline sharing functionality.""" -from account_v2.serializer import UserSerializer from pipeline_v2.models import Pipeline from rest_framework import serializers from rest_framework.serializers import SerializerMethodField @@ -10,6 +9,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): """Serializer for returning pipeline with shared user details.""" shared_users = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() created_by_email = SerializerMethodField() @@ -19,6 +19,7 @@ class Meta: "id", "pipeline_name", "shared_users", + "co_owners", "shared_to_org", "created_by", "created_by_email", @@ -26,7 +27,11 @@ class Meta: def get_shared_users(self, obj): """Get list of shared users with their details.""" - return UserSerializer(obj.shared_users.all(), many=True).data + return [{"id": u.id, "email": u.email} for u in obj.shared_users.all()] + + def get_co_owners(self, obj): + """Get list of co-owners with their details.""" + return [{"id": u.id, "email": u.email} for u in obj.co_owners.all()] def get_created_by(self, obj): """Get the creator's username.""" diff --git a/backend/pipeline_v2/urls.py b/backend/pipeline_v2/urls.py index 6dde72433b..9d368f878f 100644 --- a/backend/pipeline_v2/urls.py +++ b/backend/pipeline_v2/urls.py @@ -36,6 +36,8 @@ "get": PipelineViewSet.list_of_shared_users.__name__, } ) +pipeline_add_owner = PipelineViewSet.as_view({"post": "add_co_owner"}) +pipeline_remove_owner = PipelineViewSet.as_view({"delete": "remove_co_owner"}) pipeline_execute = PipelineViewSet.as_view({"post": "execute"}) @@ -55,6 +57,16 @@ list_shared_users, name="pipeline-shared-users", ), + 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 0d902a792c..ef46b80ad9 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -9,6 +9,7 @@ from django.db import IntegrityError from django.db.models import F, QuerySet from django.http import HttpResponse +from permissions.co_owner_views import CoOwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from plugins import get_plugin from rest_framework import serializers, status, viewsets @@ -42,9 +43,21 @@ logger = logging.getLogger(__name__) -class PipelineViewSet(viewsets.ModelViewSet): +class PipelineViewSet(CoOwnerManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning queryset = Pipeline.objects.all() + notification_resource_name_field = "pipeline_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + """Return the ResourceType value based on pipeline type.""" + from plugins.notification.constants import ResourceType + + type_map = { + "ETL": ResourceType.ETL.value, + "TASK": ResourceType.TASK.value, + } + return type_map.get(resource.pipeline_type) + pagination_class = CustomPagination filter_backends = [OrderingFilter] ordering_fields = ["created_at", "last_run_time", "pipeline_name", "run_count"] @@ -52,7 +65,13 @@ class PipelineViewSet(viewsets.ModelViewSet): # DRF's ordering attribute doesn't support nulls_last natively 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()] @@ -84,7 +103,7 @@ def get_queryset(self) -> QuerySet: F("created_at").desc(), ) - return queryset + return queryset.prefetch_related("co_owners") def get_serializer_class(self) -> serializers.Serializer: if self.action == "execute": diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0007_customtool_co_owners.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0007_customtool_co_owners.py new file mode 100644 index 0000000000..f612d7197d --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0007_customtool_co_owners.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.1 on 2026-02-23 14:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("prompt_studio_core_v2", "0006_add_custom_data_to_customtool"), + ] + + operations = [ + migrations.AddField( + model_name="customtool", + name="co_owners", + field=models.ManyToManyField( + blank=True, + help_text="Users with full ownership privileges", + related_name="co_owned_custom_tools", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_backfill_creator_to_co_owners.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_backfill_creator_to_co_owners.py new file mode 100644 index 0000000000..30f7f4266c --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0008_backfill_creator_to_co_owners.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def backfill_creator_to_co_owners(apps, schema_editor): + custom_tool_model = apps.get_model("prompt_studio_core_v2", "CustomTool") + for tool in custom_tool_model.objects.filter(created_by__isnull=False): + if not tool.co_owners.filter(id=tool.created_by_id).exists(): + tool.co_owners.add(tool.created_by) + + +class Migration(migrations.Migration): + dependencies = [ + ("prompt_studio_core_v2", "0007_customtool_co_owners"), + ] + + operations = [ + migrations.RunPython( + backfill_creator_to_co_owners, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index e125b6e501..af4b7a249e 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -26,7 +26,7 @@ def for_user(self, user: User) -> QuerySet[Any]: return ( self.get_queryset() .filter( - models.Q(created_by=user) + models.Q(co_owners=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) ) @@ -151,6 +151,12 @@ class CustomTool(DefaultOrganizationMixin, BaseModel): # 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") + co_owners = models.ManyToManyField( + User, + related_name="co_owned_custom_tools", + blank=True, + help_text="Users with full ownership privileges", + ) # Field to enable organization-level sharing shared_to_org = models.BooleanField( diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index 9a90fa3583..fba3ee98f8 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -5,6 +5,7 @@ from account_v2.serializer import UserSerializer from adapter_processor_v2.models import AdapterInstance from django.core.exceptions import ObjectDoesNotExist +from permissions.co_owner_serializers import CoOwnerRepresentationMixin from rest_framework import serializers from rest_framework.exceptions import ValidationError from utils.FileValidator import FileValidator @@ -33,7 +34,9 @@ from file_management.constants import FileInformationKey as FileKey -class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): +class CustomToolSerializer( + CoOwnerRepresentationMixin, IntegrityErrorMixin, AuditSerializer +): shared_users = serializers.PrimaryKeyRelatedField( queryset=User.objects.all(), required=False, allow_null=True, many=True ) @@ -152,7 +155,10 @@ def to_representation(self, instance): # type: ignore output.append(serialized_data) data[TSKeys.PROMPTS] = output - data["created_by_email"] = instance.created_by.email + + # Co-owner information + request = self.context.get("request") + self.add_co_owner_fields(instance, data, request) return data @@ -171,6 +177,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): """Used for listing users of Custom tool.""" created_by = UserSerializer() + co_owners = UserSerializer(many=True, read_only=True) shared_users = UserSerializer(many=True) class Meta: @@ -179,6 +186,7 @@ class Meta: "tool_id", "tool_name", "created_by", + "co_owners", "shared_users", "shared_to_org", ) diff --git a/backend/prompt_studio/prompt_studio_core_v2/urls.py b/backend/prompt_studio/prompt_studio_core_v2/urls.py index 228368544a..8390e43cbd 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/urls.py +++ b/backend/prompt_studio/prompt_studio_core_v2/urls.py @@ -37,6 +37,8 @@ {"post": "single_pass_extraction"} ) prompt_studio_users = PromptStudioCoreView.as_view({"get": "list_of_shared_users"}) +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( @@ -143,5 +145,15 @@ prompt_studio_deployment_usage, name="prompt_studio_deployment_usage", ), + 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", + ), ] ) diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index f9f2e7ec7b..c0a0dc0369 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -12,6 +12,7 @@ from django.http import HttpRequest, HttpResponse from file_management.constants import FileInformationKey as FileKey from file_management.exceptions import FileNotFound +from permissions.co_owner_views import CoOwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from pipeline_v2.models import Pipeline from plugins import get_plugin @@ -84,21 +85,32 @@ logger = logging.getLogger(__name__) -class PromptStudioCoreView(viewsets.ModelViewSet): +class PromptStudioCoreView(CoOwnerManagementMixin, 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: + from plugins.notification.constants import ResourceType + + return ResourceType.TEXT_EXTRACTOR.value # type: ignore def get_permissions(self) -> list[Any]: if self.action == "destroy": return [IsOwner()] + if self.action in ["add_co_owner", "remove_co_owner"]: + return [IsOwner()] + return [IsOwnerOrSharedUserOrSharedToOrg()] def get_queryset(self) -> QuerySet | None: - return CustomTool.objects.for_user(self.request.user) + return CustomTool.objects.for_user(self.request.user).prefetch_related( + "co_owners" + ) def get_object(self): """Override get_object to trigger lazy migration when accessing tools.""" diff --git a/backend/workflow_manager/workflow_v2/migrations/0020_workflow_co_owners.py b/backend/workflow_manager/workflow_v2/migrations/0020_workflow_co_owners.py new file mode 100644 index 0000000000..41cd4ab16f --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0020_workflow_co_owners.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.1 on 2026-02-17 08:24 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("workflow_v2", "0019_remove_filehistory_trigram_index"), + ] + + operations = [ + migrations.AddField( + model_name="workflow", + name="co_owners", + field=models.ManyToManyField( + blank=True, + help_text="Users with full ownership privileges", + related_name="co_owned_workflows", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/workflow_manager/workflow_v2/migrations/0021_backfill_creator_to_co_owners.py b/backend/workflow_manager/workflow_v2/migrations/0021_backfill_creator_to_co_owners.py new file mode 100644 index 0000000000..d08c1e231a --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0021_backfill_creator_to_co_owners.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def backfill_creator_to_co_owners(apps, schema_editor): + workflow_model = apps.get_model("workflow_v2", "Workflow") + for workflow in workflow_model.objects.filter(created_by__isnull=False): + if not workflow.co_owners.filter(id=workflow.created_by_id).exists(): + workflow.co_owners.add(workflow.created_by) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow_v2", "0020_workflow_co_owners"), + ] + + operations = [ + migrations.RunPython( + backfill_creator_to_co_owners, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index a286662a19..c5bedb17c6 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -18,14 +18,14 @@ class WorkflowModelManager(DefaultOrganizationManagerMixin, models.Manager): def for_user(self, user): """Filter workflows that the user can access: - - Workflows created by the user + - Workflows co-owned by the user - Workflows shared with the user - Workflows shared with the entire organization """ from django.db.models import Q return self.filter( - Q(created_by=user) # Owned by user + Q(co_owners=user) # Co-owned by user | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization ).distinct() @@ -95,6 +95,12 @@ class ExecutionAction(models.TextChoices): shared_users = models.ManyToManyField( User, related_name="shared_workflows", blank=True ) + co_owners = models.ManyToManyField( + User, + related_name="co_owned_workflows", + blank=True, + help_text="Users with full ownership privileges", + ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this workflow is shared with the entire organization", diff --git a/backend/workflow_manager/workflow_v2/permissions.py b/backend/workflow_manager/workflow_v2/permissions.py index 8d38435156..47a8b4e869 100644 --- a/backend/workflow_manager/workflow_v2/permissions.py +++ b/backend/workflow_manager/workflow_v2/permissions.py @@ -28,16 +28,17 @@ 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("shared_users", "co_owners"), id=workflow_id, ) workflow = request._workflow_cache user = request.user - # Check access: owner OR shared user OR shared to organization + # Check access: owner OR co-owner OR shared user OR shared to org has_access = ( workflow.created_by == user + or user in workflow.co_owners.all() or user in workflow.shared_users.all() or (workflow.shared_to_org and workflow.organization == user.organization) ) diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index 6442d1e1fa..e30a0db0d2 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -2,6 +2,10 @@ from typing import Any from django.conf import settings +from permissions.co_owner_serializers import ( + CoOwnerRepresentationMixin, + SharedUserListMixin, +) from rest_framework.serializers import ( CharField, ChoiceField, @@ -27,7 +31,9 @@ logger = logging.getLogger(__name__) -class WorkflowSerializer(IntegrityErrorMixin, AuditSerializer): +class WorkflowSerializer( + CoOwnerRepresentationMixin, IntegrityErrorMixin, AuditSerializer +): tool_instances = ToolInstanceSerializer(many=True, read_only=True) class Meta: @@ -56,9 +62,8 @@ def to_representation(self, instance: Workflow) -> dict[str, str]: many=True, context=self.context, ).data - representation["created_by_email"] = ( - instance.created_by.email if instance.created_by else None - ) + request = self.context.get("request") + self.add_co_owner_fields(instance, representation, request) return representation def create(self, validated_data: dict[str, Any]) -> Any: @@ -161,22 +166,20 @@ def get_has_exceeded_limit(self, obj: FileHistory) -> bool: return obj.has_exceeded_limit(obj.workflow) -class SharedUserListSerializer(ModelSerializer): +class SharedUserListSerializer(SharedUserListMixin, ModelSerializer): """Serializer for returning workflow with shared user details.""" shared_users = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: model = Workflow - fields = ["id", "workflow_name", "shared_users", "shared_to_org", "created_by"] - - def get_shared_users(self, obj): - """Return list of shared users with id and email.""" - return [{"id": user.id, "email": user.email} for user in obj.shared_users.all()] - - def get_created_by(self, obj): - """Return creator details.""" - if obj.created_by: - return {"id": obj.created_by.id, "email": obj.created_by.email} - return None + fields = [ + "id", + "workflow_name", + "shared_users", + "co_owners", + "shared_to_org", + "created_by", + ] diff --git a/backend/workflow_manager/workflow_v2/urls/workflow.py b/backend/workflow_manager/workflow_v2/urls/workflow.py index e9f404fed2..eb7e247509 100644 --- a/backend/workflow_manager/workflow_v2/urls/workflow.py +++ b/backend/workflow_manager/workflow_v2/urls/workflow.py @@ -25,6 +25,8 @@ workflow_schema = WorkflowViewSet.as_view({"get": "get_schema"}) can_update = WorkflowViewSet.as_view({"get": "can_update"}) list_shared_users = WorkflowViewSet.as_view({"get": "list_of_shared_users"}) +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"}) @@ -50,6 +52,16 @@ list_shared_users, name="list-shared-users", ), + 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 ec6fb46b77..555b1c94f7 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -8,6 +8,7 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone from django.views.decorators.csrf import csrf_exempt +from permissions.co_owner_views import CoOwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor @@ -69,11 +70,23 @@ def make_execution_response(response: ExecutionResponse) -> Any: return ExecuteWorkflowResponseSerializer(response).data -class WorkflowViewSet(viewsets.ModelViewSet): +class WorkflowViewSet(CoOwnerManagementMixin, viewsets.ModelViewSet): versioning_class = URLPathVersioning + notification_resource_name_field = "workflow_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + from plugins.notification.constants import ResourceType + + return ResourceType.WORKFLOW.value # type: ignore 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()] @@ -90,7 +103,7 @@ def get_queryset(self) -> QuerySet: Workflow.objects.for_user(self.request.user).filter(**filter_args) if filter_args else Workflow.objects.for_user(self.request.user) - ) + ).prefetch_related("co_owners") order_by = self.request.query_params.get("order_by") if order_by == "desc": queryset = queryset.order_by("-modified_at") @@ -314,7 +327,7 @@ def execute_workflow( def activate(self, request: Request, pk: str) -> Response: workflow = WorkflowHelper.active_project_workflow(pk) - serializer = WorkflowSerializer(workflow) + serializer = WorkflowSerializer(workflow, context={"request": request}) return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=True, methods=["get"]) diff --git a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx index c55f3cdf4e..011d4aa315 100644 --- a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx +++ b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx @@ -199,7 +199,7 @@ function ConfigureConnectorModal({ connector_name: selectedConnector.connector.connector_name, }, ); - } catch (err) { + } catch (_err) { // If an error occurs while setting custom posthog event, ignore it and continue } } @@ -232,7 +232,9 @@ function ConfigureConnectorModal({ }; const handleAddFolder = () => { - if (!selectedFolderPath) return; + if (!selectedFolderPath) { + return; + } // HACK: For GDrive connectors, strip the "root/" prefix to avoid duplication // since backend will add it back during execution. This helps avoid a migration @@ -314,7 +316,10 @@ function ConfigureConnectorModal({ updatePayload.configuration = validatedFormData; } if (Object.keys(updatePayload).length > 0) { - await handleEndpointUpdate(updatePayload); + const result = await handleEndpointUpdate(updatePayload); + if (!result) { + return; + } } // Update initial values after successful save setInitialFormDataConfig(cloneDeep(validatedFormData)); @@ -482,7 +487,9 @@ function ConfigureConnectorModal({ // Helper function to render connector label const renderConnectorLabel = (connDetails, availableConnectors) => { - if (!connDetails?.id) return undefined; + if (!connDetails?.id) { + return undefined; + } const selectedConnector = availableConnectors.find( (conn) => conn.value === connDetails.id, @@ -736,7 +743,9 @@ function ConfigureConnectorModal({ connectorMode={connMode} addNewItem={handleConnectorCreated} editItemId={null} - setEditItemId={() => {}} + setEditItemId={() => { + // No-op: edit not supported in this context + }} /> )} diff --git a/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx b/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx index 41f6e0ad65..cd5012e67c 100644 --- a/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx +++ b/frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx @@ -206,7 +206,7 @@ function DsSettingsCard({ connType, endpointDetails, message }) { }) .catch((err) => { setAlertDetails(handleException(err, "Failed to update")); - throw err; + return null; }); }; diff --git a/frontend/src/components/custom-tools/add-custom-tool-form-modal/AddCustomToolFormModal.jsx b/frontend/src/components/custom-tools/add-custom-tool-form-modal/AddCustomToolFormModal.jsx index b6755343a0..704eb6ebc7 100644 --- a/frontend/src/components/custom-tools/add-custom-tool-form-modal/AddCustomToolFormModal.jsx +++ b/frontend/src/components/custom-tools/add-custom-tool-form-modal/AddCustomToolFormModal.jsx @@ -78,6 +78,15 @@ function AddCustomToolFormModal({ navigate(success?.tool_id); }) .catch((err) => { + if (err?.response?.status === 404) { + setOpen(false); + setAlertDetails({ + type: "error", + content: + "This resource is no longer accessible. It may have been removed or your access has been revoked.", + }); + return; + } handleException(err, "", setBackendErrors); }) .finally(() => { 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 6978d4e2bb..c478da9b43 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -1,20 +1,22 @@ import { ArrowDownOutlined, PlusOutlined } from "@ant-design/icons"; import { Space } from "antd"; import PropTypes from "prop-types"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, 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 { 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, @@ -69,6 +71,54 @@ function ListOfTools() { 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(), + }); useEffect(() => { getListOfTools(); @@ -81,7 +131,7 @@ function ListOfTools() { 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, }, @@ -209,7 +259,7 @@ function ListOfTools() { info: "Clicked on '+ New Project' button", }); } catch (err) { - // If an error occurs while setting custom posthog event, ignore it and continue + console.debug("PostHog event error", err); } }; @@ -220,7 +270,7 @@ function ListOfTools() { file_name: file.name, }); } catch (err) { - // If an error occurs while setting custom posthog event, ignore it and continue + console.debug("PostHog event error", err); } setIsImportLoading(true); @@ -340,6 +390,10 @@ function ListOfTools() { }); }; + const handleCoOwner = (_event, tool) => { + handleCoOwnerAction(tool.tool_id); + }; + const defaultContent = (
); @@ -401,13 +456,25 @@ function ListOfTools() { + ); } diff --git a/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx b/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx index 3b7fa94291..e239b32601 100644 --- a/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx +++ b/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx @@ -1,19 +1,20 @@ import { Col, Row } from "antd"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; +import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import { useAlertStore } from "../../../store/alert-store"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import { useSessionStore } from "../../../store/session-store"; +import { PageTitle } from "../../widgets/page-title/PageTitle.jsx"; import { DocumentManager } from "../document-manager/DocumentManager"; import { ExportReminderBar } from "../export-reminder-bar/ExportReminderBar"; import { Header } from "../header/Header"; import { SettingsModal } from "../settings-modal/SettingsModal"; import { ToolsMain } from "../tools-main/ToolsMain"; import "./ToolIde.css"; -import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; -import { PageTitle } from "../../widgets/page-title/PageTitle.jsx"; let PromptShareModal; let PromptShareLink; @@ -67,6 +68,7 @@ function ToolIde() { const axiosPrivate = useAxiosPrivate(); const handleException = useExceptionHandler(); const { setPostHogCustomEvent } = usePostHogEvents(); + const navigate = useNavigate(); const [openShareLink, setOpenShareLink] = useState(false); const [openShareConfirmation, setOpenShareConfirmation] = useState(false); const [openShareModal, setOpenShareModal] = useState(false); @@ -222,7 +224,7 @@ function ToolIde() { tool_name: details?.tool_name, }); } catch (err) { - // Ignore posthog errors + console.debug("PostHog event error", err); } } catch (err) { setAlertDetails(handleException(err, "Failed to export")); @@ -277,7 +279,7 @@ function ToolIde() { info: "Indexing completed", }); } catch (err) { - // If an error occurs while setting custom posthog event, ignore it and continue + console.debug("PostHog event error", err); } }) .catch((err) => { @@ -306,6 +308,15 @@ function ToolIde() { return res; }) .catch((err) => { + if (err?.response?.status === 404) { + setAlertDetails({ + type: "error", + content: + "This resource is no longer accessible. It may have been removed or your access has been revoked.", + }); + navigate(`/${sessionDetails?.orgName}/tools`); + return; + } throw err; }); }; 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 5bde2402f7..36c0b1a7ba 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"; @@ -20,6 +21,7 @@ import { usePromptStudioService } from "../../api/prompt-studio-service"; import { PromptStudioModal } from "../../common/PromptStudioModal"; 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"; @@ -49,6 +51,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(); @@ -245,6 +262,11 @@ function ApiDeployment() { downloadPostmanCollection(apiDeploymentsApiService, deployment.id); }; + const handleManageCoOwners = (deployment) => { + if (!deployment?.id) return; + handleCoOwnerAction(deployment.id); + }; + // Card view configuration const apiDeploymentCardConfig = useMemo( () => @@ -261,6 +283,7 @@ function ApiDeployment() { onSetupNotifications: handleSetupNotificationsDeployment, onCodeSnippets: handleCodeSnippetsDeployment, onDownloadPostman: handleDownloadPostmanDeployment, + onManageCoOwners: handleManageCoOwners, listContext: { page: pagination.current, pageSize: pagination.pageSize, @@ -351,13 +374,25 @@ function ApiDeployment() { + ); } 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/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx b/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx index 8562b32640..b3ff4b4bb2 100644 --- a/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx +++ b/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx @@ -170,6 +170,16 @@ const CreateApiDeploymentModal = ({ }); }) .catch((err) => { + if (err?.response?.status === 404) { + setOpen(false); + clearFormDetails(); + setAlertDetails({ + type: "error", + content: + "This resource is no longer accessible. It may have been removed or your access has been revoked.", + }); + return; + } if (err.response?.data) { setBackendErrors(err.response.data); } else { diff --git a/frontend/src/components/pipelines-or-deployments/pipeline-service.js b/frontend/src/components/pipelines-or-deployments/pipeline-service.js index 4101cff7a3..9a0bad9760 100644 --- a/frontend/src/components/pipelines-or-deployments/pipeline-service.js +++ b/frontend/src/components/pipelines-or-deployments/pipeline-service.js @@ -120,6 +120,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 870ddfdd83..59db2624aa 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,14 +19,22 @@ 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 { 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([]); @@ -53,6 +55,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(); @@ -311,6 +328,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( () => @@ -331,6 +353,7 @@ function Pipelines({ type }) { onManageKeys: handleManageKeysPipeline, onSetupNotifications: handleSetupNotificationsPipeline, onDownloadPostman: handleDownloadPostmanPipeline, + onManageCoOwners: handleManageCoOwners, // Loading states isClearingFileHistory, // Pipeline type for status pill navigation @@ -429,7 +452,7 @@ function Pipelines({ type }) { )} + {coOwnerOpen && ( + + )} ); } diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx index 9bf7c0f2c0..0e122938bf 100644 --- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx @@ -1,21 +1,23 @@ 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 { 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", @@ -47,6 +49,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, @@ -204,6 +255,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); @@ -212,7 +275,7 @@ function ToolSettings({ type }) { info: `Clicked on '+ ${btnText[type]}' button`, }); } catch (err) { - // If an error occurs while setting custom posthog event, ignore it and continue + console.debug("PostHog event error", err); } }; @@ -264,6 +327,7 @@ function ToolSettings({ type }) { centered isClickable={false} handleShare={handleShare} + handleCoOwner={handleCoOwner} showOwner={true} type="Adapter" /> @@ -281,13 +345,25 @@ function ToolSettings({ type }) { + ); } diff --git a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx index dbc2f7a94f..ef9754f408 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 ? ( + + ) : ( + <> +