From 221f050010eb852024b82147a760dddcc620887c Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Fri, 3 Jul 2026 14:29:39 +0530 Subject: [PATCH 1/2] Phase-B --- .../0005_adapter_co_owner_membership.py | 105 ++++++++ backend/adapter_processor_v2/models.py | 30 ++- backend/adapter_processor_v2/serializers.py | 9 + backend/adapter_processor_v2/urls.py | 8 + backend/adapter_processor_v2/views.py | 22 +- backend/api_v2/api_deployment_views.py | 25 +- ...0005_api_deployment_co_owner_membership.py | 104 ++++++++ backend/api_v2/models.py | 31 ++- backend/api_v2/serializers.py | 17 ++ backend/api_v2/urls.py | 12 + .../0007_connector_co_owner_membership.py | 103 ++++++++ backend/connector_v2/models.py | 31 ++- backend/connector_v2/serializers.py | 50 +++- backend/connector_v2/urls.py | 14 ++ backend/connector_v2/views.py | 33 ++- backend/permissions/membership_serializers.py | 63 +++++ backend/permissions/membership_views.py | 118 +++++++++ backend/permissions/models.py | 59 +++++ backend/permissions/permission.py | 28 ++- backend/permissions/roles.py | 12 + .../0005_pipeline_co_owner_membership.py | 103 ++++++++ backend/pipeline_v2/models.py | 31 ++- backend/pipeline_v2/serializers/crud.py | 9 + backend/pipeline_v2/serializers/sharing.py | 5 + backend/pipeline_v2/urls.py | 12 + backend/pipeline_v2/views.py | 28 ++- backend/prompt_studio/permission.py | 4 +- .../0009_custom_tool_co_owner_membership.py | 104 ++++++++ .../prompt_studio_core_v2/models.py | 33 ++- .../prompt_studio_core_v2/serializers.py | 16 ++ .../prompt_studio_core_v2/urls.py | 12 + .../prompt_studio_core_v2/views.py | 21 +- .../0022_workflow_co_owner_membership.py | 103 ++++++++ .../workflow_v2/models/__init__.py | 2 +- .../workflow_v2/models/execution.py | 9 +- .../workflow_v2/models/workflow.py | 31 ++- .../workflow_v2/serializers.py | 8 + .../workflow_v2/urls/workflow.py | 8 + backend/workflow_manager/workflow_v2/views.py | 25 +- .../list-of-tools/ListOfTools.jsx | 79 +++++- .../custom-tools/view-tools/ViewTools.jsx | 3 + .../api-deployment/ApiDeployment.jsx | 35 +++ .../ApiDeploymentCardConfig.jsx | 7 +- .../api-deployment/api-deployments-service.js | 17 ++ .../pipeline-service.js | 17 ++ .../pipelines/PipelineCardConfig.jsx | 7 +- .../pipelines/Pipelines.jsx | 51 +++- .../tool-settings/ToolSettings.jsx | 86 ++++++- .../card-grid-view/CardFieldComponents.jsx | 39 ++- .../widgets/card-grid-view/CardGridView.css | 19 ++ .../co-owner-management/CoOwnerManagement.css | 17 ++ .../co-owner-management/CoOwnerManagement.jsx | 232 ++++++++++++++++++ .../components/widgets/list-view/ListView.css | 20 ++ .../components/widgets/list-view/ListView.jsx | 86 +++++-- .../workflows/workflow/Workflows.jsx | 64 ++++- .../workflows/workflow/workflow-service.js | 22 ++ frontend/src/hooks/useCoOwnerManagement.jsx | 156 ++++++++++++ frontend/src/pages/ConnectorsPage.jsx | 74 +++++- 58 files changed, 2367 insertions(+), 102 deletions(-) create mode 100644 backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py create mode 100644 backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py create mode 100644 backend/connector_v2/migrations/0007_connector_co_owner_membership.py create mode 100644 backend/permissions/membership_serializers.py create mode 100644 backend/permissions/membership_views.py create mode 100644 backend/permissions/models.py create mode 100644 backend/permissions/roles.py create mode 100644 backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py create mode 100644 backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py create mode 100644 backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py create mode 100644 frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css create mode 100644 frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx create mode 100644 frontend/src/hooks/useCoOwnerManagement.jsx diff --git a/backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py b/backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py new file mode 100644 index 0000000000..5571902b48 --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0005_adapter_co_owner_membership.py @@ -0,0 +1,105 @@ +import logging + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +logger = logging.getLogger(__name__) + +OWNER = "owner" + + +def backfill_creator_as_owner(apps, schema_editor): + """Add each adapter's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Adapters with a null ``created_by`` (SET_NULL orphans / + frictionless) are skipped — they have no owner and stay reachable only via + org-admin / service-account overrides. + """ + AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance") + AdapterMember = apps.get_model("adapter_processor_v2", "AdapterMember") + skipped = 0 + for adapter in AdapterInstance.objects.iterator(): + if not adapter.created_by_id: + skipped += 1 + continue + AdapterMember.objects.get_or_create( + adapter=adapter, + user_id=adapter.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s adapters with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("adapter_processor_v2", "0004_alter_adapterinstance_organization"), + ] + + operations = [ + migrations.CreateModel( + name="AdapterMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "adapter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="adapter_processor_v2.adapterinstance", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "adapter_member", + "unique_together": {("user", "adapter")}, + }, + ), + migrations.AddField( + model_name="adapterinstance", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this adapter.", + related_name="adapters_member_of", + through="adapter_processor_v2.AdapterMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="adaptermember", + index=models.Index( + fields=["adapter", "role"], name="adapter_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 4d589f0c8f..06e5f8eeba 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -8,6 +8,7 @@ from django.conf import settings from django.db import models from django.db.models import QuerySet +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.models import OrganizationMember from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.exceptions import InvalidEncryptionKey @@ -49,7 +50,7 @@ def for_user(self, user: User) -> QuerySet[Any]: return ( self.get_queryset() .filter( - models.Q(created_by=user) + models.Q(members=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) | models.Q(is_friction_less=True) @@ -59,7 +60,7 @@ def for_user(self, user: User) -> QuerySet[Any]: ) -class AdapterInstance(DefaultOrganizationMixin, BaseModel): +class AdapterInstance(HasMembersMixin, DefaultOrganizationMixin, BaseModel): id = models.UUIDField( primary_key=True, default=uuid.uuid4, @@ -144,6 +145,14 @@ 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") + # Owner (and, later, viewer) access lives here via the AdapterMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="AdapterMember", + related_name="adapters_member_of", + help_text="Users with a role (owner/viewer) on this adapter.", + ) description = models.TextField(blank=True, null=True, default=None) # ``shared_groups`` is stored polymorphically in @@ -201,6 +210,23 @@ def get_context_window_size(self) -> int: return 0 +class AdapterMember(ResourceMemberBase): + """Per-user role (owner/viewer) on an ``AdapterInstance``.""" + + adapter = models.ForeignKey( + AdapterInstance, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "adapter_member" + unique_together = [("user", "adapter")] + indexes = [ + models.Index(fields=["adapter", "role"], name="adapter_member_role_idx") + ] + + class UserDefaultAdapter(BaseModel): organization_member = models.OneToOneField( OrganizationMember, diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 65ece42a16..0f154160c3 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -208,6 +208,10 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: else: rep["created_by_email"] = instance.created_by.email + request = self.context.get("request") + rep["is_owner"] = instance.is_owner(request.user) if request else False + rep["co_owners_count"] = instance.co_owners_count() + return rep @@ -219,6 +223,7 @@ class SharedUserListSerializer(BaseAdapterSerializer): shared_users = serializers.SerializerMethodField() shared_groups = serializers.SerializerMethodField() + co_owners = serializers.SerializerMethodField() created_by = UserSerializer() class Meta(BaseAdapterSerializer.Meta): @@ -232,6 +237,7 @@ class Meta(BaseAdapterSerializer.Meta): "shared_users", "shared_to_org", "shared_groups", + "co_owners", ) # type: ignore def get_shared_users(self, obj): @@ -242,6 +248,9 @@ def get_shared_users(self, obj): def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return UserSerializer(obj.owners(), many=True).data + class UserDefaultAdapterSerializer(ModelSerializer): class Meta: diff --git a/backend/adapter_processor_v2/urls.py b/backend/adapter_processor_v2/urls.py index 9217169e6f..d65857aacd 100644 --- a/backend/adapter_processor_v2/urls.py +++ b/backend/adapter_processor_v2/urls.py @@ -27,6 +27,8 @@ adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"}) adapter_share = AdapterInstanceViewSet.as_view({"post": "share"}) adapter_effective_members = AdapterInstanceViewSet.as_view({"get": "effective_members"}) +adapter_owners = AdapterInstanceViewSet.as_view({"post": "add_co_owner"}) +adapter_owner_detail = AdapterInstanceViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( [ path("adapter_schema/", adapter_schema, name="get_adapter_schema"), @@ -47,5 +49,11 @@ adapter_effective_members, name="adapter-effective-members", ), + path("adapter//owners/", adapter_owners, name="adapter-owners"), + path( + "adapter//owners//", + adapter_owner_detail, + name="adapter-owner-detail", + ), ] ) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 4cfee77d01..5d6675be30 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -6,6 +6,7 @@ from django.db.models import ProtectedError, QuerySet from django.http import HttpRequest from django.http.response import HttpResponse +from permissions.membership_views import OwnerManagementMixin from permissions.permission import ( IsFrictionLessAdapter, IsFrictionLessAdapterDelete, @@ -13,6 +14,7 @@ IsOwnerOrSharedUserOrSharedToOrg, ) from permissions.resource_share_views import ResourceShareManagementMixin +from permissions.roles import ResourceRole from plugins import get_plugin from rest_framework import status from rest_framework.decorators import action @@ -137,8 +139,21 @@ def test(self, request: Request) -> Response: ) -class AdapterInstanceViewSet(ResourceShareManagementMixin, ModelViewSet): +class AdapterInstanceViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, ModelViewSet +): serializer_class = AdapterInstanceSerializer + notification_resource_name_field = "adapter_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + if not notification_plugin: + return None + return { + "LLM": ResourceType.LLM.value, + "EMBEDDING": ResourceType.EMBEDDING.value, + "VECTOR_DB": ResourceType.VECTOR_DB.value, + "X2TEXT": ResourceType.X2TEXT.value, + }.get(resource.adapter_type, ResourceType.LLM.value) def get_permissions(self) -> list[Any]: # Frictionless adapters: hidden from non-owners (update/retrieve), @@ -200,6 +215,11 @@ def create(self, request: Any) -> Response: ) instance = serializer.save() + # ``created_by`` is audit-only; the creator's access flows through + # an OWNER membership row (UN-2202 co-owners). + instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) organization_member = OrganizationMemberService.get_user_by_id( request.user.id ) diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index 5416212896..5796c87211 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -5,8 +5,10 @@ from django.db.models import F, OuterRef, QuerySet, Subquery from django.http import HttpResponse +from permissions.membership_views import OwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin +from permissions.roles import ResourceRole from plugins import get_plugin from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry from rest_framework import serializers, status, views, viewsets @@ -237,11 +239,25 @@ def get( ) -class APIDeploymentViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class APIDeploymentViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): pagination_class = CustomPagination + notification_resource_name_field = "display_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + if not notification_plugin: + return None + return ResourceType.API_DEPLOYMENT.value def get_permissions(self) -> list[Any]: - if self.action in ["destroy", "partial_update", "update"]: + if self.action in [ + "destroy", + "partial_update", + "update", + "add_co_owner", + "remove_co_owner", + ]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -297,6 +313,11 @@ def create( serializer: Serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + serializer.instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) api_key = DeploymentHelper.create_api_key(serializer=serializer, request=request) response_serializer = DeploymentResponseSerializer( {"api_key": api_key.api_key, **serializer.data} diff --git a/backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py b/backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py new file mode 100644 index 0000000000..9dbbf13c76 --- /dev/null +++ b/backend/api_v2/migrations/0005_api_deployment_co_owner_membership.py @@ -0,0 +1,104 @@ +import logging + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +logger = logging.getLogger(__name__) + +OWNER = "owner" + + +def backfill_creator_as_owner(apps, schema_editor): + """Add each API deployment's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Deployments with a null ``created_by`` are skipped. + """ + APIDeployment = apps.get_model("api_v2", "APIDeployment") + APIDeploymentMember = apps.get_model("api_v2", "APIDeploymentMember") + skipped = 0 + for dep in APIDeployment.objects.iterator(): + if not dep.created_by_id: + skipped += 1 + continue + APIDeploymentMember.objects.get_or_create( + api_deployment=dep, + user_id=dep.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s API deployments with null created_by (no owner backfilled).", + skipped, + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api_v2", "0004_alter_apideployment_organization_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="APIDeploymentMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "api_deployment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="api_v2.apideployment", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "api_deployment_member", + "unique_together": {("user", "api_deployment")}, + }, + ), + migrations.AddField( + model_name="apideployment", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this API deployment.", + related_name="api_deployments_member_of", + through="api_v2.APIDeploymentMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="apideploymentmember", + index=models.Index( + fields=["api_deployment", "role"], name="apidep_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index e4d52aeee5..d97d62159b 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -7,6 +7,7 @@ from django.db.models import Q from django.db.models.signals import post_delete from django.dispatch import receiver +from permissions.models import HasMembersMixin, ResourceMemberBase from pipeline_v2.models import Pipeline from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager @@ -46,14 +47,14 @@ def for_user(self, user): user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) return self.filter( - Q(created_by=user) # Owned by user + Q(members=user) # Owner or direct viewer (created_by is audit-only) | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class APIDeployment(DefaultOrganizationMixin, BaseModel): +class APIDeployment(HasMembersMixin, DefaultOrganizationMixin, BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) display_name = models.CharField( max_length=API_NAME_MAX_LENGTH, @@ -123,6 +124,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the APIDeploymentMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="APIDeploymentMember", + related_name="api_deployments_member_of", + help_text="Users with a role (owner/viewer) on this API deployment.", + ) + # Manager objects = APIDeploymentModelManager() @@ -176,6 +186,23 @@ class Meta: ] +class APIDeploymentMember(ResourceMemberBase): + """Per-user role (owner/viewer) on an ``APIDeployment``.""" + + api_deployment = models.ForeignKey( + APIDeployment, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "api_deployment_member" + unique_together = [("user", "api_deployment")] + indexes = [ + models.Index(fields=["api_deployment", "role"], name="apidep_member_role_idx") + ] + + class OrganizationRateLimit(DefaultOrganizationMixin, BaseModel): """Model to store organization-specific API deployment rate limits.""" diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 282adc8a4a..4135d76555 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -456,6 +456,8 @@ class APIDeploymentListSerializer(ModelSerializer): last_5_run_statuses = SerializerMethodField() run_count = SerializerMethodField() last_run_time = SerializerMethodField() + is_owner = SerializerMethodField() + co_owners_count = SerializerMethodField() class Meta: model = APIDeployment @@ -473,12 +475,21 @@ class Meta: "last_5_run_statuses", "run_count", "last_run_time", + "is_owner", + "co_owners_count", ] def get_created_by_email(self, obj): """Get the email of the creator.""" return obj.created_by.email if obj.created_by else None + def get_is_owner(self, obj) -> bool: + request = self.context.get("request") + return obj.is_owner(request.user) if request else False + + def get_co_owners_count(self, obj) -> int: + return obj.co_owners_count() + def get_run_count(self, instance) -> int: """Get total execution count for this API deployment.""" return WorkflowExecution.objects.filter(pipeline_id=instance.id).count() @@ -534,6 +545,7 @@ class SharedUserListSerializer(ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: @@ -544,6 +556,7 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", ] @@ -562,3 +575,7 @@ def get_created_by(self, obj): if obj.created_by: return {"id": obj.created_by.id, "email": obj.created_by.email} return None + + def get_co_owners(self, obj): + """Return co-owners (OWNER members) with id and email.""" + return [{"id": u.id, "email": u.email} for u in obj.owners()] diff --git a/backend/api_v2/urls.py b/backend/api_v2/urls.py index 72c1472275..eafc3c9a93 100644 --- a/backend/api_v2/urls.py +++ b/backend/api_v2/urls.py @@ -35,6 +35,8 @@ ) deployment_share = APIDeploymentViewSet.as_view({"post": "share"}) deployment_effective_members = APIDeploymentViewSet.as_view({"get": "effective_members"}) +deployment_owners = APIDeploymentViewSet.as_view({"post": "add_co_owner"}) +deployment_owner_detail = APIDeploymentViewSet.as_view({"delete": "remove_co_owner"}) execute = DeploymentExecution.as_view() @@ -75,6 +77,16 @@ deployment_effective_members, name="api_deployment_effective_members", ), + path( + "deployment//owners/", + deployment_owners, + name="api_deployment_owners", + ), + path( + "deployment//owners//", + deployment_owner_detail, + name="api_deployment_owner_detail", + ), path( "deployment/by-prompt-studio-tool/", by_prompt_studio_tool, diff --git a/backend/connector_v2/migrations/0007_connector_co_owner_membership.py b/backend/connector_v2/migrations/0007_connector_co_owner_membership.py new file mode 100644 index 0000000000..67c6c2b92e --- /dev/null +++ b/backend/connector_v2/migrations/0007_connector_co_owner_membership.py @@ -0,0 +1,103 @@ +import logging + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +logger = logging.getLogger(__name__) + +OWNER = "owner" + + +def backfill_creator_as_owner(apps, schema_editor): + """Add each connector's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Connectors with a null ``created_by`` are skipped. + """ + ConnectorInstance = apps.get_model("connector_v2", "ConnectorInstance") + ConnectorMember = apps.get_model("connector_v2", "ConnectorMember") + skipped = 0 + for connector in ConnectorInstance.objects.iterator(): + if not connector.created_by_id: + skipped += 1 + continue + ConnectorMember.objects.get_or_create( + connector=connector, + user_id=connector.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s connectors with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("connector_v2", "0006_alter_connectorinstance_organization"), + ] + + operations = [ + migrations.CreateModel( + name="ConnectorMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "connector", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="connector_v2.connectorinstance", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "connector_member", + "unique_together": {("user", "connector")}, + }, + ), + migrations.AddField( + model_name="connectorinstance", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this connector.", + related_name="connectors_member_of", + through="connector_v2.ConnectorMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="connectormember", + index=models.Index( + fields=["connector", "role"], name="connector_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 946bab005b..56191a223d 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -7,6 +7,7 @@ from connector_processor.connector_processor import ConnectorProcessor from connector_processor.constants import ConnectorKeys from django.db import models +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.fields import EncryptedBinaryField from utils.models.base_model import BaseModel, BaseModelManager @@ -42,7 +43,7 @@ def for_user(self, user: User) -> models.QuerySet: return ( self.get_queryset() .filter( - models.Q(created_by=user) + models.Q(members=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) | models.Q(pk__in=group_shared_ids) @@ -51,7 +52,7 @@ def for_user(self, user: User) -> models.QuerySet: ) -class ConnectorInstance(DefaultOrganizationMixin, BaseModel): +class ConnectorInstance(HasMembersMixin, DefaultOrganizationMixin, BaseModel): class ConnectorType(models.TextChoices): INPUT = "INPUT", "Input" OUTPUT = "OUTPUT", "Output" @@ -120,6 +121,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the ConnectorMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="ConnectorMember", + related_name="connectors_member_of", + help_text="Users with a role (owner/viewer) on this connector.", + ) + objects = ConnectorInstanceModelManager() @staticmethod @@ -170,3 +180,20 @@ class Meta: name="unique_organization_connector", ), ] + + +class ConnectorMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``ConnectorInstance``.""" + + connector = models.ForeignKey( + ConnectorInstance, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "connector_member" + unique_together = [("user", "connector")] + indexes = [ + models.Index(fields=["connector", "role"], name="connector_member_role_idx") + ] diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index 00e3c667df..e595250b3e 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -9,7 +9,13 @@ from connector_processor.constants import ConnectorKeys from connector_processor.exceptions import InvalidConnectorID, OAuthTimeOut from rest_framework import serializers -from rest_framework.serializers import CharField, SerializerMethodField, ValidationError +from rest_framework.serializers import ( + CharField, + ModelSerializer, + SerializerMethodField, + ValidationError, +) +from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field @@ -165,4 +171,46 @@ def to_representation(self, instance: ConnectorInstance) -> dict[str, str]: # Remove sensitive connector auth from the response rep.pop(CIKey.CONNECTOR_AUTH) + request = self.context.get("request") + rep["is_owner"] = instance.is_owner(request.user) if request else False + rep["co_owners_count"] = instance.co_owners_count() + return rep + + +class SharedUserListSerializer(ModelSerializer): + """Connector with shared user + group + co-owner details.""" + + shared_users = SerializerMethodField() + shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() + created_by = SerializerMethodField() + + class Meta: + model = ConnectorInstance + fields = [ + "id", + "connector_name", + "shared_users", + "shared_to_org", + "shared_groups", + "co_owners", + "created_by", + ] + + def get_shared_users(self, obj): + return [ + {"id": u.id, "email": u.email} + for u in obj.shared_users.filter(is_service_account=False) + ] + + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + + def get_co_owners(self, obj): + return [{"id": u.id, "email": u.email} for u in obj.owners()] + + def get_created_by(self, obj): + if obj.created_by: + return {"id": obj.created_by.id, "email": obj.created_by.email} + return None diff --git a/backend/connector_v2/urls.py b/backend/connector_v2/urls.py index 18f31d1a41..cf36bb8bb5 100644 --- a/backend/connector_v2/urls.py +++ b/backend/connector_v2/urls.py @@ -14,6 +14,9 @@ ) connector_share = CIViewSet.as_view({"post": "share"}) connector_effective_members = CIViewSet.as_view({"get": "effective_members"}) +connector_users = CIViewSet.as_view({"get": "list_of_shared_users"}) +connector_add_owner = CIViewSet.as_view({"post": "add_co_owner"}) +connector_remove_owner = CIViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( [ @@ -25,5 +28,16 @@ connector_effective_members, name="connector-effective-members", ), + path("connector/users//", connector_users, name="connector-users"), + path( + "connector//owners/", + connector_add_owner, + name="connector-add-owner", + ), + path( + "connector//owners//", + connector_remove_owner, + name="connector-remove-owner", + ), ] ) diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index f6803b047d..17cc382616 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -8,10 +8,13 @@ from connector_processor.exceptions import OAuthTimeOut from django.db import IntegrityError from django.db.models import ProtectedError, QuerySet +from permissions.membership_views import OwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin +from permissions.roles import ResourceRole from plugins import get_plugin from rest_framework import status, viewsets +from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -24,7 +27,7 @@ from .exceptions import DeleteConnectorInUseError from .models import ConnectorInstance -from .serializers import ConnectorInstanceSerializer +from .serializers import ConnectorInstanceSerializer, SharedUserListSerializer notification_plugin = get_plugin("notification") if notification_plugin: @@ -34,12 +37,26 @@ logger = logging.getLogger(__name__) -class ConnectorInstanceViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class ConnectorInstanceViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): versioning_class = URLPathVersioning serializer_class = ConnectorInstanceSerializer + notification_resource_name_field = "connector_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + if not notification_plugin: + return None + return ResourceType.CONNECTOR.value def get_permissions(self) -> list[Any]: - if self.action in ["update", "destroy", "partial_update"]: + if self.action in [ + "update", + "destroy", + "partial_update", + "add_co_owner", + "remove_co_owner", + ]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -182,9 +199,19 @@ def create(self, request: Any) -> Response: f"{CIKey.CONNECTOR_EXISTS}, \ {CIKey.DUPLICATE_API}" ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + serializer.instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + @action(detail=True, methods=["get"]) + def list_of_shared_users(self, request: Request, pk: Any = None) -> Response: + connector = self.get_object() + return Response(SharedUserListSerializer(connector).data) + def perform_destroy(self, instance: ConnectorInstance) -> None: """Override perform_destroy to handle ProtectedError gracefully. diff --git a/backend/permissions/membership_serializers.py b/backend/permissions/membership_serializers.py new file mode 100644 index 0000000000..78afe2543a --- /dev/null +++ b/backend/permissions/membership_serializers.py @@ -0,0 +1,63 @@ +from django.db import transaction +from rest_framework import serializers +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +from permissions.roles import ResourceRole + + +class AddOwnerSerializer(serializers.Serializer): + """Validate and add a user as an OWNER of the resource in context. + + Context requires ``resource``. A user already holding VIEWER access is + promoted to OWNER. + """ + + user_id = serializers.IntegerField() + + def validate_user_id(self, value: int) -> int: + resource = self.context["resource"] + organization = UserContext.get_organization() + if not OrganizationMember.objects.filter( + user__id=value, organization=organization + ).exists(): + raise serializers.ValidationError("User not found in your organization.") + if resource.memberships.filter(user_id=value, role=ResourceRole.OWNER).exists(): + raise serializers.ValidationError("User is already an owner.") + return value + + def save(self, **kwargs) -> None: + resource = self.context["resource"] + # Reverse manager auto-scopes to this resource; promote a viewer or + # create the owner row. + resource.memberships.update_or_create( + user_id=self.validated_data["user_id"], + defaults={"role": ResourceRole.OWNER}, + ) + + +class RemoveOwnerSerializer(serializers.Serializer): + """Validate and remove an OWNER, guarding the last-owner invariant. + + Context requires ``resource`` and ``user_to_remove``. + """ + + def validate(self, attrs: dict) -> dict: + resource = self.context["resource"] + user = self.context["user_to_remove"] + if not resource.memberships.filter(user=user, role=ResourceRole.OWNER).exists(): + raise serializers.ValidationError("User is not an owner of this resource.") + return attrs + + def save(self, **kwargs) -> None: + resource = self.context["resource"] + user = self.context["user_to_remove"] + with transaction.atomic(): + # Lock the resource so a concurrent removal can't drop the last two + # owners at once. + locked = type(resource).objects.select_for_update().get(pk=resource.pk) + if locked.memberships.filter(role=ResourceRole.OWNER).count() <= 1: + raise serializers.ValidationError( + "Cannot remove the last owner. Add another owner first." + ) + locked.memberships.filter(user=user, role=ResourceRole.OWNER).delete() diff --git a/backend/permissions/membership_views.py b/backend/permissions/membership_views.py new file mode 100644 index 0000000000..c14306b091 --- /dev/null +++ b/backend/permissions/membership_views.py @@ -0,0 +1,118 @@ +import logging +from typing import Any + +from account_v2.models import User +from django.shortcuts import get_object_or_404 +from plugins import get_plugin +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from permissions.membership_serializers import AddOwnerSerializer, RemoveOwnerSerializer +from permissions.roles import ResourceRole + +logger = logging.getLogger(__name__) + +notification_plugin = get_plugin("notification") + + +class OwnerManagementMixin: + """Owner (co-owner) management actions for a resource ViewSet. + + Owners are ``OWNER`` rows in the resource's ``memberships`` through model. + Notifications reuse the user-sharing path (``SharingNotificationService``); + a ViewSet opts in by setting ``notification_resource_name_field`` and + overriding ``get_notification_resource_type``. + """ + + notification_resource_name_field: str | None = None + + def get_notification_resource_type(self, resource: Any) -> str | None: + return None + + @action(detail=True, methods=["post"], url_path="owners") + def add_co_owner(self, request: Request, pk: str | None = None) -> Response: + resource = self.get_object() + serializer = AddOwnerSerializer( + data=request.data, context={"request": request, "resource": resource} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + added_user = User.objects.get(id=serializer.validated_data["user_id"]) + self._notify_owner_added(resource, added_user, request.user) + return Response( + {"id": str(resource.pk), "co_owners": self._owner_refs(resource)}, + status=status.HTTP_200_OK, + ) + + @action(detail=True, methods=["delete"], url_path=r"owners/(?P[^/.]+)") + def remove_co_owner( + self, request: Request, pk: str | None = None, user_id: str | None = None + ) -> Response: + resource = self.get_object() + user_to_remove = get_object_or_404(User, id=user_id) + serializer = RemoveOwnerSerializer( + data={}, + context={ + "request": request, + "resource": resource, + "user_to_remove": user_to_remove, + }, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + self._notify_owner_removed(resource, user_to_remove, request.user) + return Response(status=status.HTTP_204_NO_CONTENT) + + @staticmethod + def _owner_refs(resource: Any) -> list[dict[str, Any]]: + return [ + {"id": membership.user_id, "email": membership.user.email} + for membership in resource.memberships.filter( + role=ResourceRole.OWNER + ).select_related("user") + ] + + # --- notifications: reuse the user-sharing service, best-effort --- + + def _notification_context(self, resource: Any) -> tuple[str, str] | None: + if not notification_plugin or not self.notification_resource_name_field: + return None + resource_type = self.get_notification_resource_type(resource) + resource_name = getattr(resource, self.notification_resource_name_field, None) + if resource_type is None or not resource_name: + return None + return resource_type, resource_name + + def _notify_owner_added(self, resource: Any, user: User, actor: Any) -> None: + ctx = self._notification_context(resource) + if ctx is None: + return + resource_type, resource_name = ctx + try: + notification_plugin["service_class"]().send_sharing_notification( + resource_type=resource_type, + resource_name=resource_name, + resource_id=str(resource.pk), + shared_by=actor, + shared_to=[user], + resource_instance=resource, + ) + except Exception as e: + logger.exception("Failed to send co-owner added notification: %s", e) + + def _notify_owner_removed(self, resource: Any, user: User, actor: Any) -> None: + ctx = self._notification_context(resource) + if ctx is None: + return + resource_type, resource_name = ctx + try: + notification_plugin["service_class"]().send_access_removed_notification( + resource_type=resource_type, + resource_name=resource_name, + removed_from=[user], + removed_by=actor, + ) + except Exception as e: + logger.exception("Failed to send co-owner removed notification: %s", e) diff --git a/backend/permissions/models.py b/backend/permissions/models.py new file mode 100644 index 0000000000..86cdbf30fd --- /dev/null +++ b/backend/permissions/models.py @@ -0,0 +1,59 @@ +from typing import Any + +from django.conf import settings +from django.db import models + +from permissions.roles import ResourceRole + + +class ResourceMemberBase(models.Model): + """Abstract membership row linking a user to a resource with a role. + + Concrete per-resource subclasses add the resource FK with + ``related_name="memberships"`` plus ``unique_together`` and an index. + Ownership (and, later, viewer access) lives here; the resource's + ``created_by`` becomes audit-only. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="+", # queried from the resource side only + ) + role = models.CharField( + max_length=16, + choices=ResourceRole.choices, + default=ResourceRole.VIEWER, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True + + +class HasMembersMixin: + """Owner accessors for resource models backed by the membership table. + + Requires the resource to expose the ``memberships`` reverse relation from + its per-resource membership through model. + """ + + def owner_memberships(self) -> "models.QuerySet[Any]": + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.OWNER + ).select_related("user") + + def owners(self) -> list[Any]: + return [membership.user for membership in self.owner_memberships()] + + def co_owners_count(self) -> int: + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.OWNER + ).count() + + def is_owner(self, user: Any) -> bool: + if user is None: + return False + return self.memberships.filter( # type: ignore[attr-defined] + user=user, role=ResourceRole.OWNER + ).exists() diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 601b17eb3f..9d7a3e2aef 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -58,6 +58,22 @@ def _is_organization_admin(request: Request) -> bool: return is_admin +def _is_resource_owner(user: Any, obj: Any) -> bool: + """True if ``user`` owns ``obj``. + + Resources migrated to the membership model expose ``memberships`` — owner + means an OWNER-role row (creator + co-owners). Resources not yet migrated + fall back to ``created_by`` (co-owners rollout, UN-2202). ``created_by`` is + no longer consulted once a resource adopts the membership model. + """ + memberships = getattr(obj, "memberships", None) + if memberships is None: + return obj.created_by == user + from permissions.roles import ResourceRole + + return memberships.filter(user=user, role=ResourceRole.OWNER).exists() + + class IsOwner(permissions.BasePermission): """Allow owners and org admins. @@ -70,7 +86,7 @@ class IsOwner(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if _is_service_account(request): return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True if _is_organization_admin(request): return True @@ -87,7 +103,7 @@ def is_workflow_mutator(request: Request, workflow: Any) -> bool: """ if _is_service_account(request): return True - if workflow.created_by == request.user: + if _is_resource_owner(request.user, workflow): return True return _is_organization_admin(request) @@ -120,7 +136,7 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - obj.created_by == request.user + _is_resource_owner(request.user, obj) or obj.shared_users.filter(pk=request.user.pk).exists() or has_group_access(request.user, obj) or _is_organization_admin(request) @@ -134,7 +150,7 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - obj.created_by == request.user + _is_resource_owner(request.user, obj) or obj.shared_users.filter(pk=request.user.pk).exists() or obj.shared_to_org or has_group_access(request.user, obj) @@ -158,7 +174,7 @@ def has_object_permission( return False if _is_service_account(request): return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True return _is_organization_admin(request) @@ -173,6 +189,6 @@ def has_object_permission( ) -> bool: if obj.is_friction_less: return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True return _is_organization_admin(request) diff --git a/backend/permissions/roles.py b/backend/permissions/roles.py new file mode 100644 index 0000000000..df42d2824f --- /dev/null +++ b/backend/permissions/roles.py @@ -0,0 +1,12 @@ +from django.db import models + + +class ResourceRole(models.TextChoices): + """Access role a user holds on a shared resource. + + ``OWNER`` ≈ creator / co-owner (full control); ``VIEWER`` ≈ shared user + (read / use). A future ``EDITOR`` is a one-line addition here. + """ + + OWNER = "owner", "Owner" + VIEWER = "viewer", "Viewer" diff --git a/backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py b/backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py new file mode 100644 index 0000000000..c598f50713 --- /dev/null +++ b/backend/pipeline_v2/migrations/0005_pipeline_co_owner_membership.py @@ -0,0 +1,103 @@ +import logging + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +logger = logging.getLogger(__name__) + +OWNER = "owner" + + +def backfill_creator_as_owner(apps, schema_editor): + """Add each pipeline's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Pipelines with a null ``created_by`` are skipped. + """ + Pipeline = apps.get_model("pipeline_v2", "Pipeline") + PipelineMember = apps.get_model("pipeline_v2", "PipelineMember") + skipped = 0 + for pipeline in Pipeline.objects.iterator(): + if not pipeline.created_by_id: + skipped += 1 + continue + PipelineMember.objects.get_or_create( + pipeline=pipeline, + user_id=pipeline.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s pipelines with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("pipeline_v2", "0004_alter_pipeline_organization"), + ] + + operations = [ + migrations.CreateModel( + name="PipelineMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "pipeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="pipeline_v2.pipeline", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "pipeline_member", + "unique_together": {("user", "pipeline")}, + }, + ), + migrations.AddField( + model_name="pipeline", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this pipeline.", + related_name="pipelines_member_of", + through="pipeline_v2.PipelineMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="pipelinemember", + index=models.Index( + fields=["pipeline", "role"], name="pipeline_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 9cbdcd34f6..27f862dbdd 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db import models from django.db.models import Q +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -41,14 +42,14 @@ def for_user(self, user): group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) return self.filter( - Q(created_by=user) # Owned by user + Q(members=user) # Owner or direct viewer (created_by is audit-only) | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class Pipeline(DefaultOrganizationMixin, BaseModel): +class Pipeline(HasMembersMixin, DefaultOrganizationMixin, BaseModel): """Model to hold data related to Pipelines.""" class PipelineType(models.TextChoices): @@ -139,6 +140,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the PipelineMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="PipelineMember", + related_name="pipelines_member_of", + help_text="Users with a role (owner/viewer) on this pipeline.", + ) + # Manager objects = PipelineModelManager() @@ -179,3 +189,20 @@ class Meta: def is_active(self) -> bool: return bool(self.active) + + +class PipelineMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``Pipeline``.""" + + pipeline = models.ForeignKey( + Pipeline, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "pipeline_member" + unique_together = [("user", "pipeline")] + indexes = [ + models.Index(fields=["pipeline", "role"], name="pipeline_member_role_idx") + ] diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index 1f9aa6fe62..b8a0e46a27 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -30,6 +30,8 @@ class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): created_by_email = SerializerMethodField() last_5_run_statuses = SerializerMethodField() next_run_time = SerializerMethodField() + is_owner = SerializerMethodField() + co_owners_count = SerializerMethodField() # ``shared_groups`` is no longer an M2M on Pipeline — declare it # explicitly so ``fields = "__all__"`` continues to expose it. Share # mutations go through ``POST /pipeline/{id}/share/`` (UN-2977 plan §B). @@ -216,6 +218,13 @@ def get_created_by_email(self, obj): """Get the creator's email address.""" return obj.created_by.email if obj.created_by else None + def get_is_owner(self, obj) -> bool: + request = self.context.get("request") + return obj.is_owner(request.user) if request else False + + def get_co_owners_count(self, obj) -> int: + return obj.co_owners_count() + def get_last_5_run_statuses(self, instance: Pipeline) -> list[dict]: """Fetch the last 5 execution statuses with timestamps for this pipeline.""" return WorkflowExecution.get_last_run_statuses(instance.id, limit=5) diff --git a/backend/pipeline_v2/serializers/sharing.py b/backend/pipeline_v2/serializers/sharing.py index 43c172504b..9e7afd1df5 100644 --- a/backend/pipeline_v2/serializers/sharing.py +++ b/backend/pipeline_v2/serializers/sharing.py @@ -12,6 +12,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() created_by_email = SerializerMethodField() @@ -23,6 +24,7 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", "created_by_email", ] @@ -36,6 +38,9 @@ def get_shared_users(self, obj): def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return UserSerializer(obj.owners(), many=True).data + def get_created_by(self, obj): """Get the creator's username.""" return obj.created_by.username if obj.created_by else None diff --git a/backend/pipeline_v2/urls.py b/backend/pipeline_v2/urls.py index 513edd5389..11a44cdcb2 100644 --- a/backend/pipeline_v2/urls.py +++ b/backend/pipeline_v2/urls.py @@ -40,6 +40,8 @@ pipeline_execute = PipelineViewSet.as_view({"post": "execute"}) pipeline_share = PipelineViewSet.as_view({"post": "share"}) pipeline_effective_members = PipelineViewSet.as_view({"get": "effective_members"}) +pipeline_add_owner = PipelineViewSet.as_view({"post": "add_co_owner"}) +pipeline_remove_owner = PipelineViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( @@ -67,6 +69,16 @@ pipeline_effective_members, name="pipeline-effective-members", ), + path( + "pipeline//owners/", + pipeline_add_owner, + name="pipeline-add-owner", + ), + path( + "pipeline//owners//", + pipeline_remove_owner, + name="pipeline-remove-owner", + ), path( "pipeline/api/postman_collection//", download_postman_collection, diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py index 2cc4aab05d..741e0680af 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -9,8 +9,10 @@ from django.db import IntegrityError from django.db.models import F, QuerySet from django.http import HttpResponse +from permissions.membership_views import OwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin +from permissions.roles import ResourceRole from plugins import get_plugin from rest_framework import serializers, status, viewsets from rest_framework.decorators import action @@ -43,7 +45,9 @@ logger = logging.getLogger(__name__) -class PipelineViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class PipelineViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): versioning_class = URLPathVersioning queryset = Pipeline.objects.all() pagination_class = CustomPagination @@ -51,9 +55,24 @@ class PipelineViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): ordering_fields = ["created_at", "last_run_time", "pipeline_name", "run_count"] # Note: Default ordering with nulls_last is applied in get_queryset() # DRF's ordering attribute doesn't support nulls_last natively + notification_resource_name_field = "pipeline_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + # Only ETL/TASK pipelines map to a notification ResourceType. + if not notification_plugin: + return None + if resource.pipeline_type in (ResourceType.ETL.value, ResourceType.TASK.value): + return resource.pipeline_type + return None def get_permissions(self) -> list[Any]: - if self.action in ["destroy", "partial_update", "update"]: + if self.action in [ + "destroy", + "partial_update", + "update", + "add_co_owner", + "remove_co_owner", + ]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -132,6 +151,11 @@ def create(self, request: Request) -> Response: raise DuplicateData( f"{PipelineErrors.PIPELINE_EXISTS}, {PipelineErrors.DUPLICATE_API}" ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + pipeline_instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) return Response(data=serializer.data, status=status.HTTP_201_CREATED) def perform_destroy(self, instance: Pipeline) -> None: diff --git a/backend/prompt_studio/permission.py b/backend/prompt_studio/permission.py index d3e967e60a..859d1e7c31 100644 --- a/backend/prompt_studio/permission.py +++ b/backend/prompt_studio/permission.py @@ -1,6 +1,6 @@ from typing import Any -from permissions.permission import has_group_access +from permissions.permission import _is_resource_owner, has_group_access from rest_framework import permissions from rest_framework.request import Request from rest_framework.views import APIView @@ -20,7 +20,7 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if getattr(request.user, "is_service_account", False): return True tool = obj.tool_id - if tool.created_by == request.user: + if _is_resource_owner(request.user, tool): return True if tool.shared_users.filter(pk=request.user.pk).exists(): return True diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py new file mode 100644 index 0000000000..0f74554dde --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_custom_tool_co_owner_membership.py @@ -0,0 +1,104 @@ +import logging + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +logger = logging.getLogger(__name__) + +OWNER = "owner" + + +def backfill_creator_as_owner(apps, schema_editor): + """Add each custom tool's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Tools with a null ``created_by`` are skipped. + """ + CustomTool = apps.get_model("prompt_studio_core_v2", "CustomTool") + CustomToolMember = apps.get_model("prompt_studio_core_v2", "CustomToolMember") + skipped = 0 + for tool in CustomTool.objects.iterator(): + if not tool.created_by_id: + skipped += 1 + continue + CustomToolMember.objects.get_or_create( + custom_tool=tool, + user_id=tool.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s custom tools with null created_by (no owner backfilled).", + skipped, + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("prompt_studio_core_v2", "0008_alter_customtool_organization"), + ] + + operations = [ + migrations.CreateModel( + name="CustomToolMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "custom_tool", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="prompt_studio_core_v2.customtool", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "custom_tool_member", + "unique_together": {("user", "custom_tool")}, + }, + ), + migrations.AddField( + model_name="customtool", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this custom tool.", + related_name="custom_tools_member_of", + through="prompt_studio_core_v2.CustomToolMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="customtoolmember", + index=models.Index( + fields=["custom_tool", "role"], name="custom_tool_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index 7b11320098..7b3103a134 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -6,6 +6,7 @@ from adapter_processor_v2.models import AdapterInstance from django.db import models from django.db.models import QuerySet +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.file_storage.constants import FileStorageKeys from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper @@ -38,7 +39,7 @@ def for_user(self, user: User) -> QuerySet[Any]: return ( self.get_queryset() .filter( - models.Q(created_by=user) + models.Q(members=user) | models.Q(shared_users=user) | models.Q(shared_to_org=True) | models.Q(pk__in=group_shared_ids) @@ -47,7 +48,7 @@ def for_user(self, user: User) -> QuerySet[Any]: ) -class CustomTool(DefaultOrganizationMixin, BaseModel): +class CustomTool(HasMembersMixin, DefaultOrganizationMixin, BaseModel): """Model to store the custom tools designed in the tool studio.""" tool_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -189,6 +190,15 @@ def shared_groups(self): db_comment="Timestamp of the last successful export; NULL if never exported since the field was introduced.", ) + # Owner (and, later, viewer) access lives here via the CustomToolMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="CustomToolMember", + related_name="custom_tools_member_of", + help_text="Users with a role (owner/viewer) on this custom tool.", + ) + objects = CustomToolModelManager() def delete(self, organization_id=None, *args, **kwargs): @@ -221,3 +231,22 @@ class Meta: name="unique_tool_name", ), ] + + +class CustomToolMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``CustomTool``.""" + + custom_tool = models.ForeignKey( + CustomTool, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "custom_tool_member" + unique_together = [("user", "custom_tool")] + indexes = [ + models.Index( + fields=["custom_tool", "role"], name="custom_tool_member_role_idx" + ) + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index f465d31488..027e20d156 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -43,6 +43,8 @@ class CustomToolListSerializer(serializers.ModelSerializer): created_by_email = serializers.SerializerMethodField() prompt_count = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() + co_owners_count = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -58,11 +60,20 @@ class Meta: "icon", "created_by_email", "prompt_count", + "is_owner", + "co_owners_count", ] def get_created_by_email(self, instance): return instance.created_by.email if instance.created_by else "" + def get_is_owner(self, instance) -> bool: + request = self.context.get("request") + return instance.is_owner(request.user) if request else False + + def get_co_owners_count(self, instance) -> int: + return instance.co_owners_count() + def get_prompt_count(self, instance): if hasattr(instance, "_prompt_count"): return instance._prompt_count or 0 @@ -230,6 +241,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): created_by = UserSerializer() shared_users = serializers.SerializerMethodField() shared_groups = serializers.SerializerMethodField() + co_owners = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -240,6 +252,7 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", ) def get_shared_users(self, obj): @@ -250,6 +263,9 @@ def get_shared_users(self, obj): def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return UserSerializer(obj.owners(), many=True).data + class FileInfoIdeSerializer(serializers.Serializer): document_id = serializers.CharField() diff --git a/backend/prompt_studio/prompt_studio_core_v2/urls.py b/backend/prompt_studio/prompt_studio_core_v2/urls.py index 4a453fa309..290576b299 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/urls.py +++ b/backend/prompt_studio/prompt_studio_core_v2/urls.py @@ -44,6 +44,8 @@ prompt_studio_effective_members = PromptStudioCoreView.as_view( {"get": "effective_members"} ) +prompt_studio_add_owner = PromptStudioCoreView.as_view({"post": "add_co_owner"}) +prompt_studio_remove_owner = PromptStudioCoreView.as_view({"delete": "remove_co_owner"}) prompt_studio_file = PromptStudioCoreView.as_view( @@ -148,6 +150,16 @@ prompt_studio_effective_members, name="prompt-studio-effective-members", ), + path( + "prompt-studio//owners/", + prompt_studio_add_owner, + name="prompt-studio-add-owner", + ), + path( + "prompt-studio//owners//", + prompt_studio_remove_owner, + name="prompt-studio-remove-owner", + ), path( "prompt-studio/file/", prompt_studio_file, diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 001d63a838..0195ccc9e3 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -17,8 +17,10 @@ from django.utils import timezone from file_management.constants import FileInformationKey as FileKey from file_management.exceptions import FileNotFound +from permissions.membership_views import OwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin +from permissions.roles import ResourceRole from pipeline_v2.models import Pipeline from plugins import get_plugin from rest_framework import status, viewsets @@ -120,12 +122,22 @@ def _multi_var_lookup_block_response(custom_tool, prompt_ids=None): ) -class PromptStudioCoreView(ResourceShareManagementMixin, viewsets.ModelViewSet): +class PromptStudioCoreView( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): """Viewset to handle all Custom tool related operations.""" versioning_class = URLPathVersioning serializer_class = CustomToolSerializer + notification_resource_name_field = "tool_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + try: + from plugins.notification.constants import ResourceType + except ImportError: + return None + return ResourceType.TEXT_EXTRACTOR.value def get_serializer_class(self): if self.action == "list": @@ -133,7 +145,7 @@ def get_serializer_class(self): return CustomToolSerializer def get_permissions(self) -> list[Any]: - if self.action == "destroy": + if self.action in ["destroy", "add_co_owner", "remove_co_owner"]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -173,6 +185,11 @@ def create(self, request: HttpRequest) -> Response: f"{ToolStudioErrors.TOOL_NAME_EXISTS}, \ {ToolStudioErrors.DUPLICATE_API}" ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + serializer.instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) PromptStudioHelper.create_default_profile_manager( request.user, serializer.data["tool_id"] ) diff --git a/backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py b/backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py new file mode 100644 index 0000000000..0647c89146 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0022_workflow_co_owner_membership.py @@ -0,0 +1,103 @@ +import logging + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +logger = logging.getLogger(__name__) + +OWNER = "owner" + + +def backfill_creator_as_owner(apps, schema_editor): + """Add each workflow's creator as an OWNER membership row. + + ``created_by`` is now audit-only; the creator's access flows through this + OWNER row. Workflows with a null ``created_by`` are skipped. + """ + Workflow = apps.get_model("workflow_v2", "Workflow") + WorkflowMember = apps.get_model("workflow_v2", "WorkflowMember") + skipped = 0 + for workflow in Workflow.objects.iterator(): + if not workflow.created_by_id: + skipped += 1 + continue + WorkflowMember.objects.get_or_create( + workflow=workflow, + user_id=workflow.created_by_id, + defaults={"role": OWNER}, + ) + if skipped: + logger.warning( + "Skipped %s workflows with null created_by (no owner backfilled).", skipped + ) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("workflow_v2", "0021_alter_workflow_organization"), + ] + + operations = [ + migrations.CreateModel( + name="WorkflowMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="workflow_v2.workflow", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "workflow_member", + "unique_together": {("user", "workflow")}, + }, + ), + migrations.AddField( + model_name="workflow", + name="members", + field=models.ManyToManyField( + help_text="Users with a role (owner/viewer) on this workflow.", + related_name="workflows_member_of", + through="workflow_v2.WorkflowMember", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="workflowmember", + index=models.Index( + fields=["workflow", "role"], name="workflow_member_role_idx" + ), + ), + migrations.RunPython(backfill_creator_as_owner, migrations.RunPython.noop), + ] diff --git a/backend/workflow_manager/workflow_v2/models/__init__.py b/backend/workflow_manager/workflow_v2/models/__init__.py index cd3a65e189..1bb32a4335 100644 --- a/backend/workflow_manager/workflow_v2/models/__init__.py +++ b/backend/workflow_manager/workflow_v2/models/__init__.py @@ -1,7 +1,7 @@ # isort:skip_file # Do not change the order of the imports below to avoid circular dependency issues -from .workflow import Workflow # noqa: F401 +from .workflow import Workflow, WorkflowMember # noqa: F401 from .execution import WorkflowExecution # noqa: F401 from .execution_log import ExecutionLog # noqa: F401 from .file_history import FileHistory # noqa: F401 diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index e59e8faebb..bc732b113f 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -63,14 +63,15 @@ def for_user(self, user) -> QuerySet: return self.filter(workflow__organization=org) return self.all() - # Filter for workflow access - workflow_filter = Q(workflow__created_by=user) | Q(workflow__shared_users=user) + # Filter for workflow access (owner via membership, or shared user). + # ``created_by`` is audit-only (UN-2202 co-owners). + workflow_filter = Q(workflow__members=user) | Q(workflow__shared_users=user) # Filter for API deployments the user can access api_filter = Q( pipeline_id__in=models.Subquery( APIDeployment.objects.filter( - Q(created_by=user) | Q(shared_users=user) + Q(members=user) | Q(shared_users=user) ).values("id") ) ) @@ -78,7 +79,7 @@ def for_user(self, user) -> QuerySet: # Filter for Pipelines the user can access pipeline_filter = Q( pipeline_id__in=models.Subquery( - Pipeline.objects.filter(Q(created_by=user) | Q(shared_users=user)).values( + Pipeline.objects.filter(Q(members=user) | Q(shared_users=user)).values( "id" ) ) diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 1b2891937e..55cc1d72df 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -5,6 +5,7 @@ from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q +from permissions.models import HasMembersMixin, ResourceMemberBase from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -37,14 +38,14 @@ def for_user(self, user): user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) return self.filter( - Q(created_by=user) # Owned by user + Q(members=user) # Owner or direct viewer (created_by is audit-only) | Q(shared_users=user) # Shared with user | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class Workflow(DefaultOrganizationMixin, BaseModel): +class Workflow(HasMembersMixin, DefaultOrganizationMixin, BaseModel): class WorkflowType(models.TextChoices): DEFAULT = "DEFAULT", "Not ready yet" ETL = "ETL", "ETL pipeline" @@ -122,6 +123,15 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner (and, later, viewer) access lives here via the WorkflowMember + # through model; ``created_by`` is audit-only (UN-2202 co-owners). + members = models.ManyToManyField( + User, + through="WorkflowMember", + related_name="workflows_member_of", + help_text="Users with a role (owner/viewer) on this workflow.", + ) + # Manager objects = WorkflowModelManager() @@ -153,3 +163,20 @@ class Meta: name="unique_workflow_name", ), ] + + +class WorkflowMember(ResourceMemberBase): + """Per-user role (owner/viewer) on a ``Workflow``.""" + + workflow = models.ForeignKey( + Workflow, + on_delete=models.CASCADE, + related_name="memberships", + ) + + class Meta: + db_table = "workflow_member" + unique_together = [("user", "workflow")] + indexes = [ + models.Index(fields=["workflow", "role"], name="workflow_member_role_idx") + ] diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index 47edf54d66..db1f72444d 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -80,6 +80,9 @@ def to_representation(self, instance: Workflow) -> dict[str, str]: representation["created_by_email"] = ( instance.created_by.email if instance.created_by else None ) + request = self.context.get("request") + representation["is_owner"] = instance.is_owner(request.user) if request else False + representation["co_owners_count"] = instance.co_owners_count() return representation def create(self, validated_data: dict[str, Any]) -> Any: @@ -187,6 +190,7 @@ class SharedUserListSerializer(ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: @@ -197,6 +201,7 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", ] @@ -210,6 +215,9 @@ def get_shared_users(self, obj): def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return [{"id": u.id, "email": u.email} for u in obj.owners()] + def get_created_by(self, obj): """Return creator details.""" if obj.created_by: diff --git a/backend/workflow_manager/workflow_v2/urls/workflow.py b/backend/workflow_manager/workflow_v2/urls/workflow.py index 1225a4a32c..7eee26821e 100644 --- a/backend/workflow_manager/workflow_v2/urls/workflow.py +++ b/backend/workflow_manager/workflow_v2/urls/workflow.py @@ -28,6 +28,8 @@ list_shared_users = WorkflowViewSet.as_view({"get": "list_of_shared_users"}) workflow_share = WorkflowViewSet.as_view({"post": "share"}) workflow_effective_members = WorkflowViewSet.as_view({"get": "effective_members"}) +workflow_add_owner = WorkflowViewSet.as_view({"post": "add_co_owner"}) +workflow_remove_owner = WorkflowViewSet.as_view({"delete": "remove_co_owner"}) # File History views file_history_list = FileHistoryViewSet.as_view({"get": "list"}) @@ -59,6 +61,12 @@ workflow_effective_members, name="workflow-effective-members", ), + path("/owners/", workflow_add_owner, name="workflow-add-owner"), + path( + "/owners//", + workflow_remove_owner, + name="workflow-remove-owner", + ), path("execute/", workflow_execute, name="execute-workflow"), path( "active//", diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index e0822e966f..acbdc548fc 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -7,8 +7,10 @@ from django.db.models.query import QuerySet from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt +from permissions.membership_views import OwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin +from permissions.roles import ResourceRole from pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor from plugins import get_plugin @@ -69,11 +71,25 @@ def make_execution_response(response: ExecutionResponse) -> Any: return ExecuteWorkflowResponseSerializer(response).data -class WorkflowViewSet(ResourceShareManagementMixin, viewsets.ModelViewSet): +class WorkflowViewSet( + OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet +): versioning_class = URLPathVersioning + notification_resource_name_field = "workflow_name" + + def get_notification_resource_type(self, resource: Any) -> str | None: + if not notification_plugin: + return None + return ResourceType.WORKFLOW.value def get_permissions(self) -> list[Any]: - if self.action in ["destroy", "partial_update", "update"]: + if self.action in [ + "destroy", + "partial_update", + "update", + "add_co_owner", + "remove_co_owner", + ]: return [IsOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] @@ -129,6 +145,11 @@ def perform_create(self, serializer: WorkflowSerializer) -> Workflow: workflow = serializer.save( is_active=True, ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + workflow.memberships.get_or_create( + user_id=self.request.user.id, defaults={"role": ResourceRole.OWNER} + ) try: # Create empty WorkflowEndpoints for UI compatibility # ConnectorInstances will be created when users actually configure connectors diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx index c4bf0d1684..db6b88ae8d 100644 --- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx +++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx @@ -4,18 +4,20 @@ import PropTypes from "prop-types"; import { useEffect, useMemo, useState } from "react"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement"; +import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; +import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; import { groupsService } from "../../groups/groups-service.js"; +import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; +import { SharePermission } from "../../widgets/share-permission/SharePermission"; import { AddCustomToolFormModal } from "../add-custom-tool-form-modal/AddCustomToolFormModal"; +import { ImportTool } from "../import-tool/ImportTool"; import { ViewTools } from "../view-tools/ViewTools"; import "./ListOfTools.css"; -import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; -import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; -import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar"; -import { SharePermission } from "../../widgets/share-permission/SharePermission"; -import { ImportTool } from "../import-tool/ImportTool"; const DefaultCustomButtons = ({ setOpenImportTool, @@ -71,6 +73,54 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const [isPermissionEdit, setIsPermissionEdit] = useState(false); const [isShareLoading, setIsShareLoading] = useState(false); const [allUserList, setAllUserList] = useState([]); + const promptStudioCoOwnerService = useMemo( + () => ({ + getAllUsers: () => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/users/`, + }), + getSharedUsers: (id) => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/users/${id}`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + addCoOwner: (id, userId) => + axiosPrivate({ + method: "POST", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${id}/owners/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: { user_id: userId }, + }), + removeCoOwner: (id, userId) => + axiosPrivate({ + method: "DELETE", + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/${id}/owners/${userId}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + }), + [axiosPrivate, sessionDetails?.orgId, sessionDetails?.csrfToken], + ); + + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: promptStudioCoOwnerService, + setAlertDetails, + onListRefresh: () => getListOfTools(), + }); const [allGroupList, setAllGroupList] = useState([]); useEffect(() => { @@ -84,7 +134,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { const getListOfTools = () => { const requestOptions = { method: "GET", - url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/ `, + url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/`, headers: { "X-CSRFToken": sessionDetails?.csrfToken, }, @@ -354,6 +404,10 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { }); }; + const handleCoOwner = (_event, tool) => { + handleCoOwnerAction(tool.tool_id); + }; + const defaultContent = (
); @@ -426,6 +481,18 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) { onApply={onShare} isSharableToOrg={true} /> + ); } diff --git a/frontend/src/components/custom-tools/view-tools/ViewTools.jsx b/frontend/src/components/custom-tools/view-tools/ViewTools.jsx index 6317f484ff..5dbf92d8f0 100644 --- a/frontend/src/components/custom-tools/view-tools/ViewTools.jsx +++ b/frontend/src/components/custom-tools/view-tools/ViewTools.jsx @@ -19,6 +19,7 @@ function ViewTools({ centered, isClickable = true, handleShare, + handleCoOwner, showOwner, type, }) { @@ -58,6 +59,7 @@ function ViewTools({ centered={centered} isClickable={isClickable} handleShare={handleShare} + handleCoOwner={handleCoOwner} showOwner={showOwner} type={type} /> @@ -72,6 +74,7 @@ ViewTools.propTypes = { handleEdit: PropTypes.func.isRequired, handleDelete: PropTypes.func.isRequired, handleShare: PropTypes.func, + handleCoOwner: PropTypes.func, titleProp: PropTypes.string.isRequired, descriptionProp: PropTypes.string, iconProp: PropTypes.string, diff --git a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx index 50ae379e89..b70c18edd1 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx @@ -3,6 +3,7 @@ import { useLocation } from "react-router-dom"; import { deploymentApiTypes, displayURL } from "../../../helpers/GetStaticData"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; import { useExecutionLogs } from "../../../hooks/useExecutionLogs"; import { usePaginatedList } from "../../../hooks/usePaginatedList"; @@ -21,6 +22,7 @@ import { PromptStudioModal } from "../../common/PromptStudioModal"; import { groupsService } from "../../groups/groups-service.js"; import { LogsModal } from "../../pipelines-or-deployments/log-modal/LogsModal.jsx"; import { NotificationModal } from "../../pipelines-or-deployments/notification-modal/NotificationModal.jsx"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; import { workflowService } from "../../workflows/workflow/workflow-service.js"; import { CreateApiDeploymentModal } from "../create-api-deployment-modal/CreateApiDeploymentModal"; @@ -50,6 +52,21 @@ function ApiDeployment() { const axiosPrivate = useAxiosPrivate(); const { getApiKeys, downloadPostmanCollection } = usePipelineHelper(); const [openNotificationModal, setOpenNotificationModal] = useState(false); + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: apiDeploymentsApiService, + setAlertDetails, + onListRefresh: () => getApiDeploymentList(), + }); const { count, isLoading, fetchCount } = usePromptStudioStore(); const { getPromptStudioCount } = usePromptStudioService(); @@ -249,6 +266,11 @@ function ApiDeployment() { downloadPostmanCollection(apiDeploymentsApiService, deployment.id); }; + const handleManageCoOwners = (deployment) => { + if (!deployment?.id) return; + handleCoOwnerAction(deployment.id); + }; + // Card view configuration const apiDeploymentCardConfig = useMemo( () => @@ -265,6 +287,7 @@ function ApiDeployment() { onSetupNotifications: handleSetupNotificationsDeployment, onCodeSnippets: handleCodeSnippetsDeployment, onDownloadPostman: handleDownloadPostmanDeployment, + onManageCoOwners: handleManageCoOwners, listContext: { page: pagination.current, pageSize: pagination.pageSize, @@ -363,6 +386,18 @@ function ApiDeployment() { onApply={onShare} isSharableToOrg={true} /> + ); } diff --git a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx index e5c9d67966..e08b86da75 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx @@ -38,6 +38,7 @@ function createApiDeploymentCardConfig({ onSetupNotifications, onCodeSnippets, onDownloadPostman, + onManageCoOwners, listContext, }) { return { @@ -128,7 +129,11 @@ function createApiDeploymentCardConfig({ itemId={deployment.id} listContext={listContext} /> - + onManageCoOwners?.(deployment)} + /> { + options = { + method: "POST", + url: `${path}/api/deployment/${id}/owners/`, + headers: requestHeaders, + data: { user_id: userId }, + }; + return axiosPrivate(options); + }, + removeCoOwner: (id, userId) => { + options = { + method: "DELETE", + url: `${path}/api/deployment/${id}/owners/${userId}/`, + headers: requestHeaders, + }; + return axiosPrivate(options); + }, }; } diff --git a/frontend/src/components/pipelines-or-deployments/pipeline-service.js b/frontend/src/components/pipelines-or-deployments/pipeline-service.js index db4ef62338..4858ab41ba 100644 --- a/frontend/src/components/pipelines-or-deployments/pipeline-service.js +++ b/frontend/src/components/pipelines-or-deployments/pipeline-service.js @@ -126,6 +126,23 @@ function pipelineService() { }; return axiosPrivate(requestOptions); }, + addCoOwner: (id, userId) => { + const requestOptions = { + method: "POST", + url: `${path}/pipeline/${id}/owners/`, + headers: requestHeaders, + data: { user_id: userId }, + }; + return axiosPrivate(requestOptions); + }, + removeCoOwner: (id, userId) => { + const requestOptions = { + method: "DELETE", + url: `${path}/pipeline/${id}/owners/${userId}/`, + headers: requestHeaders, + }; + return axiosPrivate(requestOptions); + }, }; } diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx index 6680c1df5b..2125041df1 100644 --- a/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx +++ b/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx @@ -240,6 +240,7 @@ function createPipelineCardConfig({ onManageKeys, onSetupNotifications, onDownloadPostman, + onManageCoOwners, isClearingFileHistory, pipelineType, listContext, @@ -369,7 +370,11 @@ function createPipelineCardConfig({ itemId={pipeline.id} listContext={listContext} /> - + onManageCoOwners?.(pipeline)} + /> {/* NEXT RUN AT row (only if scheduled) */} diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx index ec7bc779e1..9cf4703a28 100644 --- a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx +++ b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx @@ -7,14 +7,8 @@ import { deploymentsStaticContent, } from "../../../helpers/GetStaticData"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js"; -import { useAlertStore } from "../../../store/alert-store.js"; -import { useSessionStore } from "../../../store/session-store.js"; -import { Layout } from "../../deployments/layout/Layout.jsx"; -import { EtlTaskDeploy } from "../etl-task-deploy/EtlTaskDeploy.jsx"; -import FileHistoryModal from "../file-history-modal/FileHistoryModal.jsx"; -import { LogsModal } from "../log-modal/LogsModal.jsx"; -import "./Pipelines.css"; import useClearFileHistory from "../../../hooks/useClearFileHistory"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; import { useExecutionLogs } from "../../../hooks/useExecutionLogs"; import { usePaginatedList } from "../../../hooks/usePaginatedList"; @@ -25,15 +19,23 @@ import { } from "../../../hooks/usePromptStudioFetchCount"; import { useScrollRestoration } from "../../../hooks/useScrollRestoration"; import { useShareModal } from "../../../hooks/useShareModal"; +import { useAlertStore } from "../../../store/alert-store.js"; import { usePromptStudioStore } from "../../../store/prompt-studio-store"; +import { useSessionStore } from "../../../store/session-store.js"; import { usePromptStudioService } from "../../api/prompt-studio-service"; import { PromptStudioModal } from "../../common/PromptStudioModal"; +import { Layout } from "../../deployments/layout/Layout.jsx"; import { ManageKeys } from "../../deployments/manage-keys/ManageKeys.jsx"; import { groupsService } from "../../groups/groups-service.js"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; +import { EtlTaskDeploy } from "../etl-task-deploy/EtlTaskDeploy.jsx"; +import FileHistoryModal from "../file-history-modal/FileHistoryModal.jsx"; +import { LogsModal } from "../log-modal/LogsModal.jsx"; import { NotificationModal } from "../notification-modal/NotificationModal.jsx"; import { pipelineService } from "../pipeline-service.js"; import { createPipelineCardConfig } from "./PipelineCardConfig.jsx"; +import "./Pipelines.css"; function Pipelines({ type }) { const [tableData, setTableData] = useState([]); @@ -54,6 +56,21 @@ function Pipelines({ type }) { const pipelineApiService = pipelineService(); const { getApiKeys, downloadPostmanCollection } = usePipelineHelper(); const [openNotificationModal, setOpenNotificationModal] = useState(false); + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: pipelineApiService, + setAlertDetails, + onListRefresh: () => getPipelineList(), + }); const { count, isLoading, fetchCount } = usePromptStudioStore(); const { getPromptStudioCount } = usePromptStudioService(); @@ -315,6 +332,11 @@ function Pipelines({ type }) { downloadPostmanCollection(pipelineApiService, pipeline.id); }; + const handleManageCoOwners = (pipeline) => { + if (!pipeline?.id) return; + handleCoOwnerAction(pipeline.id); + }; + // Card view configuration - no actionItems needed, all handlers passed directly const pipelineCardConfig = useMemo( () => @@ -335,6 +357,7 @@ function Pipelines({ type }) { onManageKeys: handleManageKeysPipeline, onSetupNotifications: handleSetupNotificationsPipeline, onDownloadPostman: handleDownloadPostmanPipeline, + onManageCoOwners: handleManageCoOwners, // Loading states isClearingFileHistory, // Pipeline type for status pill navigation @@ -442,6 +465,20 @@ function Pipelines({ type }) { isSharableToOrg={true} /> )} + {coOwnerOpen && ( + + )} ); } diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx index 46c6153fee..e8cf142e21 100644 --- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx +++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx @@ -1,22 +1,24 @@ import { PlusOutlined } from "@ant-design/icons"; import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -import { IslandLayout } from "../../../layouts/island-layout/IslandLayout"; -import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal"; -import "../../input-output/data-source-card/DataSourceCard.css"; -import "./ToolSettings.css"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; +import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import { useListSearch } from "../../../hooks/useListSearch"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; +import { IslandLayout } from "../../../layouts/island-layout/IslandLayout"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; import { ViewTools } from "../../custom-tools/view-tools/ViewTools"; import { groupsService } from "../../groups/groups-service.js"; +import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal"; +import "../../input-output/data-source-card/DataSourceCard.css"; import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar"; +import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; import { SharePermission } from "../../widgets/share-permission/SharePermission"; +import "./ToolSettings.css"; const titles = { llm: "LLMs", @@ -50,6 +52,55 @@ function ToolSettings({ type }) { const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); const handleException = useExceptionHandler(); + + const adapterCoOwnerService = useMemo( + () => ({ + getAllUsers: () => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/users/`, + }), + getSharedUsers: (id) => + axiosPrivate({ + method: "GET", + url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/users/${id}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + addCoOwner: (id, userId) => + axiosPrivate({ + method: "POST", + url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${id}/owners/`, + headers: { + "X-CSRFToken": sessionDetails?.csrfToken, + "Content-Type": "application/json", + }, + data: { user_id: userId }, + }), + removeCoOwner: (id, userId) => + axiosPrivate({ + method: "DELETE", + url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${id}/owners/${userId}/`, + headers: { "X-CSRFToken": sessionDetails?.csrfToken }, + }), + }), + [sessionDetails?.orgId, sessionDetails?.csrfToken], + ); + + const { + coOwnerOpen, + setCoOwnerOpen, + coOwnerData, + coOwnerLoading, + coOwnerAllUsers, + coOwnerResourceId, + handleCoOwner: handleCoOwnerAction, + onAddCoOwner, + onRemoveCoOwner, + } = useCoOwnerManagement({ + service: adapterCoOwnerService, + setAlertDetails, + onListRefresh: () => getAdapters(), + }); const { posthogEventText, setPostHogCustomEvent } = usePostHogEvents(); const { listRef, @@ -216,6 +267,18 @@ function ToolSettings({ type }) { }); }; + const handleCoOwner = (_event, adapter) => { + if (!adapter?.id) return; + if (adapter?.is_deprecated) { + setAlertDetails({ + type: "error", + content: "This adapter has been deprecated and cannot be managed.", + }); + return; + } + handleCoOwnerAction(adapter.id); + }; + const handleOpenAddSourceModal = () => { setOpenAddSourcesModal(true); @@ -274,6 +337,7 @@ function ToolSettings({ type }) { centered isClickable={false} handleShare={handleShare} + handleCoOwner={handleCoOwner} showOwner={true} type="Adapter" /> @@ -299,6 +363,18 @@ function ToolSettings({ type }) { onApply={onShare} isSharableToOrg={true} /> + ); } diff --git a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx index 15ca6873cc..bffe1b747e 100644 --- a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx +++ b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx @@ -117,22 +117,44 @@ CardActionBox.propTypes = { * Reusable owner field row * @return {JSX.Element} Rendered owner field row */ -function OwnerFieldRow({ item, sessionDetails }) { - const isOwner = item.created_by === sessionDetails?.userId; +function OwnerFieldRow({ item, sessionDetails, onManageCoOwners }) { + const isOwner = item?.is_owner ?? item.created_by === sessionDetails?.userId; const email = item.created_by_email; - const ownerDisplay = isOwner ? "You" : email?.split("@")[0] || "Unknown"; + const name = isOwner ? "Me" : email?.split("@")[0] || "Unknown"; + const extra = + item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : ""; + const ownerDisplay = `${name}${extra}`; + + const ownerContent = ( + + + + {ownerDisplay} + + + ); return ( Owner - - - - {ownerDisplay} + {onManageCoOwners ? ( + + - + ) : ( + ownerContent + )} ); } @@ -140,6 +162,7 @@ function OwnerFieldRow({ item, sessionDetails }) { OwnerFieldRow.propTypes = { item: PropTypes.object.isRequired, sessionDetails: PropTypes.object, + onManageCoOwners: PropTypes.func, }; /** diff --git a/frontend/src/components/widgets/card-grid-view/CardGridView.css b/frontend/src/components/widgets/card-grid-view/CardGridView.css index 9627e3c38a..f51319cf2f 100644 --- a/frontend/src/components/widgets/card-grid-view/CardGridView.css +++ b/frontend/src/components/widgets/card-grid-view/CardGridView.css @@ -896,6 +896,25 @@ color: #1677ff; } +/* Clickable owner field - mirrors ListView owner badge behavior */ +.card-owner-clickable { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s ease; +} + +.card-owner-clickable:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.card-owner-clickable:hover .ant-typography { + color: #1677ff; +} + /* Responsive override for card-grid-view on smaller screens */ @media (max-width: 900px) { .card-grid-view { diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css new file mode 100644 index 0000000000..c7698eea62 --- /dev/null +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css @@ -0,0 +1,17 @@ +.co-owner-search { + width: 100%; + margin-bottom: 16px; +} + +.co-owner-creator-tag { + margin-left: 8px; +} + +.co-owner-modal .shared-user-avatar { + background-color: #00a6ed; + margin-right: 15px; +} + +.co-owner-modal .shared-username { + font-weight: 500; +} diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx new file mode 100644 index 0000000000..5b56ca7a7c --- /dev/null +++ b/frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx @@ -0,0 +1,232 @@ +import { + DeleteOutlined, + QuestionCircleOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { + Avatar, + Button, + List, + Modal, + Popconfirm, + Select, + Typography, +} from "antd"; +import PropTypes from "prop-types"; +import { useMemo, useState } from "react"; + +import { SpinnerLoader } from "../spinner-loader/SpinnerLoader"; +import "./CoOwnerManagement.css"; + +function CoOwnerManagement({ + open, + setOpen, + resourceId, + resourceType, + allUsers, + coOwners, + loading, + onAddCoOwner, + onRemoveCoOwner, +}) { + const [pendingAdds, setPendingAdds] = useState([]); + const [removingUserId, setRemovingUserId] = useState(null); + const [applying, setApplying] = useState(false); + + const ownersList = coOwners || []; + const totalOwners = ownersList.length; + + // Exclude both existing co-owners and pending adds from dropdown + const availableUsers = useMemo(() => { + const coOwnerIds = new Set((coOwners || []).map((u) => u?.id?.toString())); + const pendingIds = new Set(pendingAdds.map((u) => u?.id?.toString())); + return (allUsers || []).filter( + (user) => + !coOwnerIds.has(user?.id?.toString()) && + !pendingIds.has(user?.id?.toString()), + ); + }, [allUsers, coOwners, pendingAdds]); + + const handleSelect = (userId) => { + const user = (allUsers || []).find( + (u) => u?.id?.toString() === userId?.toString(), + ); + if (user) { + setPendingAdds((prev) => [...prev, user]); + } + }; + + const handleRemovePending = (userId) => { + setPendingAdds((prev) => + prev.filter((u) => u?.id?.toString() !== userId?.toString()), + ); + }; + + const handleRemoveExisting = async (userId) => { + setRemovingUserId(userId); + try { + await onRemoveCoOwner(resourceId, userId); + } finally { + setRemovingUserId(null); + } + }; + + const handleApply = async () => { + if (pendingAdds.length === 0) return; + const usersToAdd = [...pendingAdds]; + setApplying(true); + try { + const userIds = usersToAdd.map((user) => user.id); + await onAddCoOwner(resourceId, userIds); + } finally { + setPendingAdds([]); + setApplying(false); + } + }; + + const handleCancel = () => { + setPendingAdds([]); + setOpen(false); + }; + + const filterOption = (input, option) => + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()); + + const combinedList = [ + ...ownersList, + ...pendingAdds.filter( + (pending) => + !ownersList.some( + (owner) => owner?.id?.toString() === pending?.id?.toString(), + ), + ), + ]; + + return ( + + {loading || applying ? ( + + ) : ( + <> +