diff --git a/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py b/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py index 304fc835a3..34a89c9986 100644 --- a/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py +++ b/backend/adapter_processor_v2/management/commands/manage_deprecated_adapters.py @@ -235,8 +235,8 @@ def _check_usage(self, adapter: AdapterInstance) -> int: default_x2text_adapter=adapter ).count() - # Check if shared with users - usage_count += adapter.shared_users.count() + # Check if shared with direct viewers (VIEWER memberships, UN-2202) + usage_count += len(adapter.viewers()) # Check if shared to organization if adapter.shared_to_org: diff --git a/backend/adapter_processor_v2/migrations/0005_absorb_shared_users.py b/backend/adapter_processor_v2/migrations/0005_absorb_shared_users.py new file mode 100644 index 0000000000..982af15140 --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0005_absorb_shared_users.py @@ -0,0 +1,26 @@ +"""UN-2202: absorb adapter creator + shared_users into ResourceMembership. + +Backfill runs before ``shared_users`` is removed, so it can still read it. +""" + +from django.db import migrations +from tenant_account_v2.migrations._membership_backfill import backfill_memberships + +APP_LABEL = "adapter_processor_v2" +MODEL_NAME = "AdapterInstance" + + +def _forward(apps, schema_editor): + backfill_memberships(apps, APP_LABEL, MODEL_NAME) + + +class Migration(migrations.Migration): + dependencies = [ + ("adapter_processor_v2", "0004_alter_adapterinstance_organization"), + ("tenant_account_v2", "0005_resource_membership"), + ] + + operations = [ + migrations.RunPython(_forward, migrations.RunPython.noop), + migrations.RemoveField(model_name="adapterinstance", name="shared_users"), + ] diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py index 4d589f0c8f..adba66dbdb 100644 --- a/backend/adapter_processor_v2/models.py +++ b/backend/adapter_processor_v2/models.py @@ -6,10 +6,16 @@ from account_v2.models import User from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import QuerySet +from permissions.models import HasMembersMixin from tenant_account_v2.models import OrganizationMember from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import ( + resources_visible_via_groups, + resources_visible_via_memberships, +) from utils.exceptions import InvalidEncryptionKey from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -41,16 +47,14 @@ def for_user(self, user: User) -> QuerySet[Any]: if OrganizationMemberService.is_user_organization_admin(user): return self.get_queryset() - from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + member_ids = resources_visible_via_memberships(self.model, user) return ( self.get_queryset() .filter( - models.Q(created_by=user) - | models.Q(shared_users=user) + models.Q(pk__in=member_ids) | models.Q(shared_to_org=True) | models.Q(is_friction_less=True) | models.Q(pk__in=group_shared_ids) @@ -59,7 +63,7 @@ def for_user(self, user: User) -> QuerySet[Any]: ) -class AdapterInstance(DefaultOrganizationMixin, BaseModel): +class AdapterInstance(HasMembersMixin, DefaultOrganizationMixin, BaseModel): id = models.UUIDField( primary_key=True, default=uuid.uuid4, @@ -141,9 +145,10 @@ class AdapterInstance(DefaultOrganizationMixin, BaseModel): db_comment="Metadata about adapter deprecation (reason, date, replacement)", ) - # Introduced field to establish M2M relation between users and adapters. - # This will introduce intermediary table which relates both the models. - shared_users = models.ManyToManyField(User, related_name="shared_adapters_instance") + # Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in + # the polymorphic ``ResourceMembership`` table. ``created_by`` is + # audit-only; VIEWER rows succeed the former ``shared_users`` M2M. + memberships = GenericRelation("tenant_account_v2.ResourceMembership") description = models.TextField(blank=True, null=True, default=None) # ``shared_groups`` is stored polymorphically in diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 65ece42a16..51ded0d921 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -38,7 +38,6 @@ class Meta: # auto-validator that 400s on re-save before the view can handle it. validators = [] extra_kwargs = { - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -208,6 +207,10 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: else: rep["created_by_email"] = instance.created_by.email + request = self.context.get("request") + rep["is_owner"] = instance.is_owner(request.user) if request else False + rep["co_owners_count"] = instance.co_owners_count() + return rep @@ -219,6 +222,7 @@ class SharedUserListSerializer(BaseAdapterSerializer): shared_users = serializers.SerializerMethodField() shared_groups = serializers.SerializerMethodField() + co_owners = serializers.SerializerMethodField() created_by = UserSerializer() class Meta(BaseAdapterSerializer.Meta): @@ -232,16 +236,19 @@ class Meta(BaseAdapterSerializer.Meta): "shared_users", "shared_to_org", "shared_groups", + "co_owners", ) # type: ignore def get_shared_users(self, obj): - return UserSerializer( - obj.shared_users.filter(is_service_account=False), many=True - ).data + viewers = [u for u in obj.viewers() if not u.is_service_account] + return UserSerializer(viewers, many=True).data def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return UserSerializer(obj.owners(), many=True).data + class UserDefaultAdapterSerializer(ModelSerializer): class Meta: diff --git a/backend/adapter_processor_v2/urls.py b/backend/adapter_processor_v2/urls.py index 9217169e6f..d65857aacd 100644 --- a/backend/adapter_processor_v2/urls.py +++ b/backend/adapter_processor_v2/urls.py @@ -27,6 +27,8 @@ adapter_info = AdapterInstanceViewSet.as_view({"get": "adapter_info"}) adapter_share = AdapterInstanceViewSet.as_view({"post": "share"}) adapter_effective_members = AdapterInstanceViewSet.as_view({"get": "effective_members"}) +adapter_owners = AdapterInstanceViewSet.as_view({"post": "add_co_owner"}) +adapter_owner_detail = AdapterInstanceViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( [ path("adapter_schema/", adapter_schema, name="get_adapter_schema"), @@ -47,5 +49,11 @@ adapter_effective_members, name="adapter-effective-members", ), + path("adapter//owners/", adapter_owners, name="adapter-owners"), + path( + "adapter//owners//", + adapter_owner_detail, + name="adapter-owner-detail", + ), ] ) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 2d500588ed..db725c3b39 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 @@ -139,8 +141,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), @@ -159,7 +174,12 @@ def get_permissions(self) -> list[Any]: return [IsOwner()] def get_queryset(self) -> QuerySet | None: - queryset = AdapterInstance.objects.for_user(self.request.user) + # Avoid per-row queries for owner/co-owner + creator fields in list views + queryset = ( + AdapterInstance.objects.for_user(self.request.user) + .select_related("created_by") + .prefetch_related("memberships") + ) if filter_args := FilterHelper.build_filter_args( self.request, constant.ADAPTER_TYPE, @@ -230,6 +250,11 @@ def create(self, request: Any) -> Response: # consistent with the org the controlled-mode check above evaluated, # so the per-org restriction can't be sidestepped via the payload. instance = serializer.save(organization=UserContext.get_organization()) + # ``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..1adb07a152 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()] @@ -253,8 +269,11 @@ def get_queryset(self) -> QuerySet | None: .values("created_at")[:1] ) + # Avoid per-row queries for owner/co-owner + creator fields in list views queryset = ( APIDeployment.objects.for_user(self.request.user) + .select_related("created_by") + .prefetch_related("memberships") .annotate(last_run_time_annotated=Subquery(last_run_subquery)) .order_by(F("last_run_time_annotated").desc(nulls_last=True)) ) @@ -297,6 +316,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_absorb_shared_users.py b/backend/api_v2/migrations/0005_absorb_shared_users.py new file mode 100644 index 0000000000..1e8e00e5ae --- /dev/null +++ b/backend/api_v2/migrations/0005_absorb_shared_users.py @@ -0,0 +1,26 @@ +"""UN-2202: absorb API deployment creator + shared_users into ResourceMembership. + +Backfill runs before ``shared_users`` is removed, so it can still read it. +""" + +from django.db import migrations +from tenant_account_v2.migrations._membership_backfill import backfill_memberships + +APP_LABEL = "api_v2" +MODEL_NAME = "APIDeployment" + + +def _forward(apps, schema_editor): + backfill_memberships(apps, APP_LABEL, MODEL_NAME) + + +class Migration(migrations.Migration): + dependencies = [ + ("api_v2", "0004_alter_apideployment_organization_and_more"), + ("tenant_account_v2", "0005_resource_membership"), + ] + + operations = [ + migrations.RunPython(_forward, migrations.RunPython.noop), + migrations.RemoveField(model_name="apideployment", name="shared_users"), + ] diff --git a/backend/api_v2/models.py b/backend/api_v2/models.py index e4d52aeee5..e5131fd2f6 100644 --- a/backend/api_v2/models.py +++ b/backend/api_v2/models.py @@ -3,12 +3,18 @@ from typing import Any from account_v2.models import User +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import Q from django.db.models.signals import post_delete from django.dispatch import receiver +from permissions.models import HasMembersMixin from pipeline_v2.models import Pipeline from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import ( + resources_visible_via_groups, + resources_visible_via_memberships, +) from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -41,19 +47,17 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.all() - from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + member_ids = resources_visible_via_memberships(self.model, user) return self.filter( - Q(created_by=user) # Owned by user - | Q(shared_users=user) # Shared with user + Q(pk__in=member_ids) # Owner or direct viewer (created_by audit-only) | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class APIDeployment(DefaultOrganizationMixin, BaseModel): +class APIDeployment(HasMembersMixin, DefaultOrganizationMixin, BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) display_name = models.CharField( max_length=API_NAME_MAX_LENGTH, @@ -106,9 +110,6 @@ class APIDeployment(DefaultOrganizationMixin, BaseModel): editable=False, ) # Sharing fields - shared_users = models.ManyToManyField( - User, related_name="shared_api_deployments", blank=True - ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this API deployment is shared with the entire organization", @@ -123,6 +124,11 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in + # the polymorphic ``ResourceMembership`` table. ``created_by`` is + # audit-only; VIEWER rows succeed the former ``shared_users`` M2M. + memberships = GenericRelation("tenant_account_v2.ResourceMembership") + # Manager objects = APIDeploymentModelManager() diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 282adc8a4a..2a86565be9 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -48,7 +48,6 @@ class Meta: # that 400s on re-save before the mixin can map a friendly message. validators = [] extra_kwargs = { - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -456,6 +455,8 @@ class APIDeploymentListSerializer(ModelSerializer): last_5_run_statuses = SerializerMethodField() run_count = SerializerMethodField() last_run_time = SerializerMethodField() + is_owner = SerializerMethodField() + co_owners_count = SerializerMethodField() class Meta: model = APIDeployment @@ -473,12 +474,21 @@ class Meta: "last_5_run_statuses", "run_count", "last_run_time", + "is_owner", + "co_owners_count", ] def get_created_by_email(self, obj): """Get the email of the creator.""" return obj.created_by.email if obj.created_by else None + def get_is_owner(self, obj) -> bool: + request = self.context.get("request") + return obj.is_owner(request.user) if request else False + + def get_co_owners_count(self, obj) -> int: + return obj.co_owners_count() + def get_run_count(self, instance) -> int: """Get total execution count for this API deployment.""" return WorkflowExecution.objects.filter(pipeline_id=instance.id).count() @@ -534,6 +544,7 @@ class SharedUserListSerializer(ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: @@ -544,14 +555,16 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", ] def get_shared_users(self, obj): - """Return list of shared users with id and email.""" + """Return direct viewers (VIEWER members) with id and email.""" return [ {"id": user.id, "email": user.email} - for user in obj.shared_users.filter(is_service_account=False) + for user in obj.viewers() + if not user.is_service_account ] def get_shared_groups(self, obj): @@ -562,3 +575,7 @@ def get_created_by(self, obj): if obj.created_by: return {"id": obj.created_by.id, "email": obj.created_by.email} return None + + def get_co_owners(self, obj): + """Return co-owners (OWNER members) with id and email.""" + return [{"id": u.id, "email": u.email} for u in obj.owners()] diff --git a/backend/api_v2/urls.py b/backend/api_v2/urls.py index 72c1472275..eafc3c9a93 100644 --- a/backend/api_v2/urls.py +++ b/backend/api_v2/urls.py @@ -35,6 +35,8 @@ ) deployment_share = APIDeploymentViewSet.as_view({"post": "share"}) deployment_effective_members = APIDeploymentViewSet.as_view({"get": "effective_members"}) +deployment_owners = APIDeploymentViewSet.as_view({"post": "add_co_owner"}) +deployment_owner_detail = APIDeploymentViewSet.as_view({"delete": "remove_co_owner"}) execute = DeploymentExecution.as_view() @@ -75,6 +77,16 @@ deployment_effective_members, name="api_deployment_effective_members", ), + path( + "deployment//owners/", + deployment_owners, + name="api_deployment_owners", + ), + path( + "deployment//owners//", + deployment_owner_detail, + name="api_deployment_owner_detail", + ), path( "deployment/by-prompt-studio-tool/", by_prompt_studio_tool, diff --git a/backend/connector_v2/migrations/0007_absorb_shared_users.py b/backend/connector_v2/migrations/0007_absorb_shared_users.py new file mode 100644 index 0000000000..33031dea2b --- /dev/null +++ b/backend/connector_v2/migrations/0007_absorb_shared_users.py @@ -0,0 +1,26 @@ +"""UN-2202: absorb connector creator + shared_users into ResourceMembership. + +Backfill runs before ``shared_users`` is removed, so it can still read it. +""" + +from django.db import migrations +from tenant_account_v2.migrations._membership_backfill import backfill_memberships + +APP_LABEL = "connector_v2" +MODEL_NAME = "ConnectorInstance" + + +def _forward(apps, schema_editor): + backfill_memberships(apps, APP_LABEL, MODEL_NAME) + + +class Migration(migrations.Migration): + dependencies = [ + ("connector_v2", "0006_alter_connectorinstance_organization"), + ("tenant_account_v2", "0005_resource_membership"), + ] + + operations = [ + migrations.RunPython(_forward, migrations.RunPython.noop), + migrations.RemoveField(model_name="connectorinstance", name="shared_users"), + ] diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py index 946bab005b..499374d7ad 100644 --- a/backend/connector_v2/models.py +++ b/backend/connector_v2/models.py @@ -6,8 +6,14 @@ from connector_auth_v2.models import ConnectorAuth from connector_processor.connector_processor import ConnectorProcessor from connector_processor.constants import ConnectorKeys +from django.contrib.contenttypes.fields import GenericRelation from django.db import models +from permissions.models import HasMembersMixin from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import ( + resources_visible_via_groups, + resources_visible_via_memberships, +) from utils.fields import EncryptedBinaryField from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( @@ -34,16 +40,14 @@ def for_user(self, user: User) -> models.QuerySet: if OrganizationMemberService.is_user_organization_admin(user): return self.all() - from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + member_ids = resources_visible_via_memberships(self.model, user) return ( self.get_queryset() .filter( - models.Q(created_by=user) - | models.Q(shared_users=user) + models.Q(pk__in=member_ids) | models.Q(shared_to_org=True) | models.Q(pk__in=group_shared_ids) ) @@ -51,7 +55,7 @@ def for_user(self, user: User) -> models.QuerySet: ) -class ConnectorInstance(DefaultOrganizationMixin, BaseModel): +class ConnectorInstance(HasMembersMixin, DefaultOrganizationMixin, BaseModel): class ConnectorType(models.TextChoices): INPUT = "INPUT", "Input" OUTPUT = "OUTPUT", "Output" @@ -105,12 +109,6 @@ class ConnectorMode(models.TextChoices): db_comment="Is the connector shared to entire org", ) - # Introduced field to establish M2M relation between users and connectors. - # This will introduce intermediary table which relates both the models. - shared_users = models.ManyToManyField( - User, related_name="shared_connectors", blank=True - ) - # ``shared_groups`` is stored polymorphically in # ``tenant_account_v2.ResourceGroupShare``; the property preserves the # ergonomic read surface for DRF / existing callers. @@ -120,6 +118,11 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in + # the polymorphic ``ResourceMembership`` table. ``created_by`` is + # audit-only; VIEWER rows succeed the former ``shared_users`` M2M. + memberships = GenericRelation("tenant_account_v2.ResourceMembership") + objects = ConnectorInstanceModelManager() @staticmethod diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py index 00e3c667df..c1698a5181 100644 --- a/backend/connector_v2/serializers.py +++ b/backend/connector_v2/serializers.py @@ -9,7 +9,13 @@ from connector_processor.constants import ConnectorKeys from connector_processor.exceptions import InvalidConnectorID, OAuthTimeOut from rest_framework import serializers -from rest_framework.serializers import CharField, SerializerMethodField, ValidationError +from rest_framework.serializers import ( + CharField, + ModelSerializer, + SerializerMethodField, + ValidationError, +) +from tenant_account_v2.sharing_helpers import serialize_group_refs from utils.fields import EncryptedBinaryFieldSerializer from utils.input_sanitizer import validate_name_field @@ -41,7 +47,6 @@ class Meta: "connector_name": {"required": False}, # connector_mode is derived from the catalog in to_representation. "connector_mode": {"read_only": True}, - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -165,4 +170,47 @@ def to_representation(self, instance: ConnectorInstance) -> dict[str, str]: # Remove sensitive connector auth from the response rep.pop(CIKey.CONNECTOR_AUTH) + request = self.context.get("request") + rep["is_owner"] = instance.is_owner(request.user) if request else False + rep["co_owners_count"] = instance.co_owners_count() + return rep + + +class SharedUserListSerializer(ModelSerializer): + """Connector with shared user + group + co-owner details.""" + + shared_users = SerializerMethodField() + shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() + created_by = SerializerMethodField() + + class Meta: + model = ConnectorInstance + fields = [ + "id", + "connector_name", + "shared_users", + "shared_to_org", + "shared_groups", + "co_owners", + "created_by", + ] + + def get_shared_users(self, obj): + return [ + {"id": u.id, "email": u.email} + for u in obj.viewers() + if not u.is_service_account + ] + + def get_shared_groups(self, obj): + return serialize_group_refs(obj) + + def get_co_owners(self, obj): + return [{"id": u.id, "email": u.email} for u in obj.owners()] + + def get_created_by(self, obj): + if obj.created_by: + return {"id": obj.created_by.id, "email": obj.created_by.email} + return None diff --git a/backend/connector_v2/urls.py b/backend/connector_v2/urls.py index 18f31d1a41..cf36bb8bb5 100644 --- a/backend/connector_v2/urls.py +++ b/backend/connector_v2/urls.py @@ -14,6 +14,9 @@ ) connector_share = CIViewSet.as_view({"post": "share"}) connector_effective_members = CIViewSet.as_view({"get": "effective_members"}) +connector_users = CIViewSet.as_view({"get": "list_of_shared_users"}) +connector_add_owner = CIViewSet.as_view({"post": "add_co_owner"}) +connector_remove_owner = CIViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( [ @@ -25,5 +28,16 @@ connector_effective_members, name="connector-effective-members", ), + path("connector/users//", connector_users, name="connector-users"), + path( + "connector//owners/", + connector_add_owner, + name="connector-add-owner", + ), + path( + "connector//owners//", + connector_remove_owner, + name="connector-remove-owner", + ), ] ) diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index e81da3c3b2..61d73d081f 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.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response @@ -27,7 +30,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: @@ -37,12 +40,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()] @@ -69,7 +86,12 @@ def _enforce_connector_creation_restriction(request: Any) -> None: ) def get_queryset(self) -> QuerySet | None: - queryset = ConnectorInstance.objects.for_user(self.request.user) + # Avoid per-row queries for owner/co-owner + creator fields in list views + queryset = ( + ConnectorInstance.objects.for_user(self.request.user) + .select_related("created_by") + .prefetch_related("memberships") + ) filter_args = FilterHelper.build_filter_args( self.request, @@ -217,9 +239,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..fd6fa0104e --- /dev/null +++ b/backend/permissions/membership_views.py @@ -0,0 +1,120 @@ +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: # NOSONAR + # Overridable hook: subclasses dispatch on ``resource`` (e.g. adapter + # type, pipeline type); the base default ignores it. + 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..d0131e0cf4 --- /dev/null +++ b/backend/permissions/models.py @@ -0,0 +1,47 @@ +from typing import Any + +from django.db import models + +from permissions.roles import ResourceRole + + +class HasMembersMixin: + """Owner / viewer accessors for resource models. + + Requires the resource to expose a ``memberships`` relation — a + ``GenericRelation`` to :class:`tenant_account_v2.ResourceMembership`. + """ + + def owner_memberships(self) -> "models.QuerySet[Any]": + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.OWNER + ).select_related("user") + + def owners(self) -> list[Any]: + return [membership.user for membership in self.owner_memberships()] + + def viewer_memberships(self) -> "models.QuerySet[Any]": + return self.memberships.filter( # type: ignore[attr-defined] + role=ResourceRole.VIEWER + ).select_related("user") + + def viewers(self) -> list[Any]: + """Direct viewers (VIEWER role) — the membership successor to the old + ``shared_users`` M2M (UN-2202 Phase 2). + """ + return [membership.user for membership in self.viewer_memberships()] + + def co_owners_count(self) -> int: + # ``.all()`` hits the prefetch cache in list views; 1 query otherwise. + return sum( + m.role == ResourceRole.OWNER + for m in self.memberships.all() # type: ignore[attr-defined] + ) + + def is_owner(self, user: Any) -> bool: + if user is None: + return False + return any( + m.user_id == user.id and m.role == ResourceRole.OWNER + for m in self.memberships.all() # type: ignore[attr-defined] + ) diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 601b17eb3f..0118dfcfce 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -58,6 +58,39 @@ def _is_organization_admin(request: Request) -> bool: return is_admin +def _is_resource_owner(user: Any, obj: Any) -> bool: + """True if ``user`` owns ``obj``. + + Resources migrated to the membership model expose ``memberships`` — owner + means an OWNER-role row (creator + co-owners). Resources not yet migrated + fall back to ``created_by`` (co-owners rollout, UN-2202). ``created_by`` is + no longer consulted once a resource adopts the membership model. + """ + memberships = getattr(obj, "memberships", None) + if memberships is None: + return obj.created_by == user + from permissions.roles import ResourceRole + + return memberships.filter(user=user, role=ResourceRole.OWNER).exists() + + +def _is_resource_viewer(user: Any, obj: Any) -> bool: + """True if ``user`` has direct viewer access to ``obj``. + + Symmetric to :func:`_is_resource_owner`. Resources migrated to the + membership model expose ``memberships`` — a direct viewer is a VIEWER-role + row (the successor to the old ``shared_users`` M2M, UN-2202 Phase 2). + Resources not yet migrated fall back to the ``shared_users`` M2M so the + shared permission classes keep working for both. + """ + memberships = getattr(obj, "memberships", None) + if memberships is None: + return obj.shared_users.filter(pk=user.pk).exists() + from permissions.roles import ResourceRole + + return memberships.filter(user=user, role=ResourceRole.VIEWER).exists() + + class IsOwner(permissions.BasePermission): """Allow owners and org admins. @@ -70,7 +103,7 @@ class IsOwner(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: if _is_service_account(request): return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True if _is_organization_admin(request): return True @@ -87,7 +120,7 @@ def is_workflow_mutator(request: Request, workflow: Any) -> bool: """ if _is_service_account(request): return True - if workflow.created_by == request.user: + if _is_resource_owner(request.user, workflow): return True return _is_organization_admin(request) @@ -105,6 +138,26 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo return is_workflow_mutator(request, obj.workflow) +class IsParentToolOwner(permissions.BasePermission): + """Mutation gate for Prompt Studio sub-resources owned via the parent tool. + + A ``ProfileManager`` is not a membership resource, so its access is + inherited from the parent ``CustomTool``. Admits the tool's owner (creator + + co-owners), org admin, or service account -- mirrors ``IsParentWorkflowOwner`` + (UN-2202). Falls back to the object's own owner when it has no parent tool + (``prompt_studio_tool`` is nullable) to preserve legacy behaviour for + orphan rows. + """ + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if _is_service_account(request): + return True + owner_resource = obj.prompt_studio_tool or obj + if _is_resource_owner(request.user, owner_resource): + return True + return _is_organization_admin(request) + + class IsOrganizationMember(permissions.BasePermission): def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: user_organization = UserContext.get_organization() @@ -120,8 +173,8 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() + _is_resource_owner(request.user, obj) + or _is_resource_viewer(request.user, obj) or has_group_access(request.user, obj) or _is_organization_admin(request) ) @@ -134,8 +187,8 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if _is_service_account(request): return True return ( - obj.created_by == request.user - or obj.shared_users.filter(pk=request.user.pk).exists() + _is_resource_owner(request.user, obj) + or _is_resource_viewer(request.user, obj) or obj.shared_to_org or has_group_access(request.user, obj) or _is_organization_admin(request) @@ -158,7 +211,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 +226,6 @@ def has_object_permission( ) -> bool: if obj.is_friction_less: return True - if obj.created_by == request.user: + if _is_resource_owner(request.user, obj): return True return _is_organization_admin(request) diff --git a/backend/permissions/resource_share_views.py b/backend/permissions/resource_share_views.py index b4ed73bf52..1227562531 100644 --- a/backend/permissions/resource_share_views.py +++ b/backend/permissions/resource_share_views.py @@ -147,11 +147,14 @@ def diff_share_axes( def _read_axis(instance: Model, axis: str) -> set[Any]: """Return the current set of related objects on the given axis. - ``shared_groups`` is stored polymorphically in - ``ResourceGroupShare`` rather than as an M2M on the resource model - — route reads through the helper. Other axes still live as M2M - fields on the resource and use ``getattr`` access. + ``shared_users`` is the direct-viewer axis — since UN-2202 Phase 2 it + is backed by VIEWER membership rows, not an M2M (all mixin hosts are + membership-backed resources). ``shared_groups`` is stored + polymorphically in ``ResourceGroupShare`` — route reads through the + helper. """ + if axis == "shared_users": + return set(instance.viewers()) # type: ignore[attr-defined] if axis == "shared_groups": # Lazy import — ``tenant_account_v2`` depends on the permissions # package being importable during Django app loading. diff --git a/backend/permissions/roles.py b/backend/permissions/roles.py new file mode 100644 index 0000000000..df42d2824f --- /dev/null +++ b/backend/permissions/roles.py @@ -0,0 +1,12 @@ +from django.db import models + + +class ResourceRole(models.TextChoices): + """Access role a user holds on a shared resource. + + ``OWNER`` ≈ creator / co-owner (full control); ``VIEWER`` ≈ shared user + (read / use). A future ``EDITOR`` is a one-line addition here. + """ + + OWNER = "owner", "Owner" + VIEWER = "viewer", "Viewer" diff --git a/backend/permissions/tests/__init__.py b/backend/permissions/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/permissions/tests/base.py b/backend/permissions/tests/base.py new file mode 100644 index 0000000000..a5381b8a15 --- /dev/null +++ b/backend/permissions/tests/base.py @@ -0,0 +1,155 @@ +"""Shared fixtures for co-owner (OWNER-membership) management tests (UN-2202). + +One org with owner/co-owner/viewer/outsider/admin members, plus a builder table +for every OSS shareable resource so the owner-management surface can be exercised +uniformly. Mirrors ``tenant_account_v2.tests.GroupSharingTestBase``. +""" + +import secrets +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from unittest.mock import patch + +from account_v2.models import Organization, User +from permissions.permission import IsOwner +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext +from workflow_manager.workflow_v2.models.workflow import Workflow + +# Admin resolution is auth-plugin backed, so patch it to a deterministic +# predicate — the ``role="admin"`` membership row alone does not make +# ``_is_organization_admin`` true. +_ADMIN_PREDICATE = ( + "tenant_account_v2.organization_member_service." + "OrganizationMemberService.is_user_organization_admin" +) + + +def make_user(email: str, **kwargs: Any) -> User: + return User.objects.create_user( + username=email, email=email, password=secrets.token_urlsafe(), **kwargs + ) + + +def _build_workflow(org: Organization, creator: User) -> Any: + return Workflow.objects.create( + workflow_name=f"wf-{secrets.token_hex(4)}", organization=org, created_by=creator + ) + + +def _build_custom_tool(org: Organization, creator: User) -> Any: + from prompt_studio.prompt_studio_core_v2.models import CustomTool + + return CustomTool.objects.create( + tool_name=f"tool-{secrets.token_hex(4)}", + description="integration-test tool", + organization=org, + created_by=creator, + ) + + +def _build_adapter(org: Organization, creator: User) -> Any: + from adapter_processor_v2.models import AdapterInstance + + return AdapterInstance.objects.create( + adapter_name=f"adapter-{secrets.token_hex(4)}", + adapter_id="openai|test", + adapter_type="LLM", + adapter_metadata={}, + organization=org, + created_by=creator, + ) + + +def _build_connector(org: Organization, creator: User) -> Any: + from connector_v2.models import ConnectorInstance + + return ConnectorInstance.objects.create( + connector_name=f"conn-{secrets.token_hex(4)}", + connector_id="test", + organization=org, + created_by=creator, + ) + + +def _build_pipeline(org: Organization, creator: User) -> Any: + from pipeline_v2.models import Pipeline + + return Pipeline.objects.create( + pipeline_name=f"pipe-{secrets.token_hex(4)}", + workflow=_build_workflow(org, creator), + organization=org, + created_by=creator, + ) + + +def _build_api_deployment(org: Organization, creator: User) -> Any: + from api_v2.models import APIDeployment + + # api_name defaults to a 36-char UUID; the column is shorter, so pass a + # short unique name. save() derives api_endpoint from it. + return APIDeployment.objects.create( + api_name=f"api-{secrets.token_hex(4)}", + workflow=_build_workflow(org, creator), + organization=org, + created_by=creator, + ) + + +@dataclass(frozen=True) +class ResourceSpec: + """A shareable resource kind plus a builder returning a saved instance.""" + + kind: str + build: Callable[[Organization, User], Any] + + +# AgenticProject (7th shareable resource) is cloud-only — covered in the cloud +# repo's ``agentic_studio_v1`` test tree, which runs through the same rig. +RESOURCE_SPECS: list[ResourceSpec] = [ + ResourceSpec("workflow", _build_workflow), + ResourceSpec("custom_tool", _build_custom_tool), + ResourceSpec("adapter", _build_adapter), + ResourceSpec("connector", _build_connector), + ResourceSpec("pipeline", _build_pipeline), + ResourceSpec("api_deployment", _build_api_deployment), +] + + +class CoOwnerOrgTestMixin: + """setUp helper: one org, members, and a deterministic admin predicate.""" + + def _seed_org(self) -> None: + self.org = Organization.objects.create( + name="org-a", display_name="Org A", organization_id="org-a" + ) + UserContext.set_organization_identifier(self.org.organization_id) + self.owner = make_user("owner@example.com") + self.coowner = make_user("coowner@example.com") + self.viewer = make_user("viewer@example.com") + self.outsider = make_user("outsider@example.com") # org member, no access + self.admin = make_user("admin@example.com") + self.stranger = make_user("stranger@example.com") # NOT an org member + for user in (self.owner, self.coowner, self.viewer, self.outsider): + OrganizationMember.objects.create( + organization=self.org, user=user, role="user" + ) + OrganizationMember.objects.create( + organization=self.org, user=self.admin, role="admin" + ) + patcher = patch( + _ADMIN_PREDICATE, + side_effect=lambda u: getattr(u, "email", None) == self.admin.email, + ) + patcher.start() + # addCleanup is provided by TestCase (this is a mixin combined with it). + self.addCleanup(patcher.stop) # type: ignore[attr-defined] + + def _is_owner_perm(self, user: User, obj: object) -> bool: + """Run ``IsOwner.has_object_permission`` for ``user`` against ``obj``.""" + request = APIRequestFactory().get("/") + request.user = user + return IsOwner().has_object_permission(request, APIView(), obj) diff --git a/backend/permissions/tests/test_owner_management.py b/backend/permissions/tests/test_owner_management.py new file mode 100644 index 0000000000..7613992387 --- /dev/null +++ b/backend/permissions/tests/test_owner_management.py @@ -0,0 +1,206 @@ +"""Integration tests for co-owner (OWNER-membership) management (UN-2202). + +Exercises the shared owner-management surface — the ``owners/`` endpoints, +``AddOwnerSerializer`` / ``RemoveOwnerSerializer`` (incl. VIEWER->OWNER promotion +and the last-owner guard), the ``IsOwner`` permission class, ``for_user`` +visibility, and ``HasMembersMixin`` — on Workflow, plus a parameterized sweep +across the OSS shareable resources. + +DB-backed (Django ``TestCase``), so ``backend/conftest.py`` auto-marks these +``integration`` and the rig runs them in ``integration-backend``. +""" + +import pytest +from account_v2.models import User +from django.test import TestCase +from permissions.roles import ResourceRole +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory, force_authenticate +from workflow_manager.workflow_v2.models.workflow import Workflow +from workflow_manager.workflow_v2.views import WorkflowViewSet + +from permissions.membership_serializers import AddOwnerSerializer +from permissions.tests.base import ( + RESOURCE_SPECS, + CoOwnerOrgTestMixin, + make_user, +) + + +class WorkflowOwnerEndpointTests(CoOwnerOrgTestMixin, TestCase): + """The ``owners/`` add/remove endpoints via the real WorkflowViewSet.""" + + def setUp(self) -> None: + self._seed_org() + self.workflow = Workflow.objects.create( + workflow_name="wf-1", organization=self.org, created_by=self.owner + ) + self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER) + self.factory = APIRequestFactory() + + def _add(self, actor: User, target_id: int) -> Response: + view = WorkflowViewSet.as_view({"post": "add_co_owner"}) + request = self.factory.post("/x/", {"user_id": target_id}, format="json") + force_authenticate(request, user=actor) + return view(request, pk=str(self.workflow.pk)) + + def _remove(self, actor: User, user_id: int) -> Response: + view = WorkflowViewSet.as_view({"delete": "remove_co_owner"}) + request = self.factory.delete("/x/") + force_authenticate(request, user=actor) + return view(request, pk=str(self.workflow.pk), user_id=str(user_id)) + + def _owner_ids(self) -> set[int]: + return set( + self.workflow.memberships.filter(role=ResourceRole.OWNER).values_list( + "user_id", flat=True + ) + ) + + @pytest.mark.critical_path("co-owner-manage") + def test_owner_adds_co_owner(self) -> None: + response = self._add(self.owner, self.coowner.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.coowner.pk, self._owner_ids()) + self.assertIn(self.coowner.pk, [ref["id"] for ref in response.data["co_owners"]]) + + def test_add_rejects_non_org_member(self) -> None: + response = self._add(self.owner, self.stranger.pk) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_rejects_existing_owner(self) -> None: + response = self._add(self.owner, self.owner.pk) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_promotes_viewer_to_owner(self) -> None: + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + response = self._add(self.owner, self.viewer.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.viewer.pk, self._owner_ids()) + # promotion, not duplication: exactly one row for the viewer + self.assertEqual(self.workflow.memberships.filter(user=self.viewer).count(), 1) + + def test_viewer_cannot_add_co_owner(self) -> None: + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + response = self._add(self.viewer, self.coowner.pk) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_outsider_gets_404(self) -> None: + # for_user() filters the resource out before IsOwner runs → 404, not 403. + response = self._add(self.outsider, self.coowner.pk) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_admin_can_add_co_owner(self) -> None: + response = self._add(self.admin, self.coowner.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_owner_removes_co_owner(self) -> None: + self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER) + response = self._remove(self.owner, self.coowner.pk) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertNotIn(self.coowner.pk, self._owner_ids()) + + def test_remove_rejects_non_owner(self) -> None: + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + response = self._remove(self.owner, self.viewer.pk) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_cannot_remove_last_owner(self) -> None: + response = self._remove(self.owner, self.owner.pk) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(self.owner.pk, self._owner_ids()) + + def test_can_remove_when_multiple_owners(self) -> None: + self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER) + response = self._remove(self.owner, self.owner.pk) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(self._owner_ids(), {self.coowner.pk}) + + +class OwnerModelAndPermissionTests(CoOwnerOrgTestMixin, TestCase): + """IsOwner branches, for_user visibility, HasMembersMixin, save() org-derivation.""" + + def setUp(self) -> None: + self._seed_org() + self.workflow = Workflow.objects.create( + workflow_name="wf-1", organization=self.org, created_by=self.owner + ) + self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER) + + def test_is_owner_permission_branches(self) -> None: + self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER) + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + svc = make_user("svc@example.com", is_service_account=True) + self.assertTrue(self._is_owner_perm(self.owner, self.workflow)) + self.assertTrue(self._is_owner_perm(self.coowner, self.workflow)) + self.assertTrue(self._is_owner_perm(self.admin, self.workflow)) + self.assertTrue(self._is_owner_perm(svc, self.workflow)) + self.assertFalse(self._is_owner_perm(self.viewer, self.workflow)) + self.assertFalse(self._is_owner_perm(self.outsider, self.workflow)) + + def test_created_by_not_consulted_when_memberships_exist(self) -> None: + # A creator with no OWNER row is not an owner — created_by is audit-only. + orphan = Workflow.objects.create( + workflow_name="wf-2", organization=self.org, created_by=self.outsider + ) + self.assertFalse(self._is_owner_perm(self.outsider, orphan)) + + def test_for_user_visibility_tracks_membership(self) -> None: + self.assertNotIn(self.workflow, Workflow.objects.for_user(self.coowner)) + self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER) + self.assertIn(self.workflow, Workflow.objects.for_user(self.coowner)) + self.assertNotIn(self.workflow, Workflow.objects.for_user(self.outsider)) + + def test_has_members_mixin_accessors(self) -> None: + self.workflow.memberships.create(user=self.coowner, role=ResourceRole.OWNER) + self.workflow.memberships.create(user=self.viewer, role=ResourceRole.VIEWER) + self.assertEqual(self.workflow.co_owners_count(), 2) + self.assertTrue(self.workflow.is_owner(self.owner)) + self.assertTrue(self.workflow.is_owner(self.coowner)) + self.assertFalse(self.workflow.is_owner(self.viewer)) + self.assertEqual( + {u.id for u in self.workflow.owners()}, {self.owner.pk, self.coowner.pk} + ) + self.assertEqual({u.id for u in self.workflow.viewers()}, {self.viewer.pk}) + + def test_membership_save_derives_organization(self) -> None: + # organization is server-derived from the resource, never passed in. + membership = self.workflow.memberships.create( + user=self.viewer, role=ResourceRole.VIEWER + ) + self.assertEqual(membership.organization_id, self.org.pk) + + +class CrossResourceOwnerManagementTests(CoOwnerOrgTestMixin, TestCase): + """The shared owner-management surface behaves identically for every OSS + shareable resource (AgenticProject is covered cloud-side).""" + + def setUp(self) -> None: + self._seed_org() + + def test_add_owner_and_visibility_per_resource(self) -> None: + for spec in RESOURCE_SPECS: + with self.subTest(kind=spec.kind): + resource = spec.build(self.org, self.owner) + resource.memberships.create(user=self.owner, role=ResourceRole.OWNER) + + serializer = AddOwnerSerializer( + data={"user_id": self.coowner.pk}, context={"resource": resource} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + owner_ids = set( + resource.memberships.filter(role=ResourceRole.OWNER).values_list( + "user_id", flat=True + ) + ) + self.assertEqual(owner_ids, {self.owner.pk, self.coowner.pk}) + self.assertTrue(resource.is_owner(self.coowner)) + + manager = type(resource).objects + self.assertIn(resource, manager.for_user(self.coowner)) + self.assertNotIn(resource, manager.for_user(self.outsider)) + self.assertTrue(self._is_owner_perm(self.coowner, resource)) + self.assertFalse(self._is_owner_perm(self.outsider, resource)) diff --git a/backend/pipeline_v2/migrations/0005_absorb_shared_users.py b/backend/pipeline_v2/migrations/0005_absorb_shared_users.py new file mode 100644 index 0000000000..b655839e6b --- /dev/null +++ b/backend/pipeline_v2/migrations/0005_absorb_shared_users.py @@ -0,0 +1,26 @@ +"""UN-2202: absorb pipeline creator + shared_users into ResourceMembership. + +Backfill runs before ``shared_users`` is removed, so it can still read it. +""" + +from django.db import migrations +from tenant_account_v2.migrations._membership_backfill import backfill_memberships + +APP_LABEL = "pipeline_v2" +MODEL_NAME = "Pipeline" + + +def _forward(apps, schema_editor): + backfill_memberships(apps, APP_LABEL, MODEL_NAME) + + +class Migration(migrations.Migration): + dependencies = [ + ("pipeline_v2", "0004_alter_pipeline_organization"), + ("tenant_account_v2", "0005_resource_membership"), + ] + + operations = [ + migrations.RunPython(_forward, migrations.RunPython.noop), + migrations.RemoveField(model_name="pipeline", name="shared_users"), + ] diff --git a/backend/pipeline_v2/models.py b/backend/pipeline_v2/models.py index 9cbdcd34f6..a92f030272 100644 --- a/backend/pipeline_v2/models.py +++ b/backend/pipeline_v2/models.py @@ -2,9 +2,15 @@ from account_v2.models import User from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import Q +from permissions.models import HasMembersMixin from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import ( + resources_visible_via_groups, + resources_visible_via_memberships, +) from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -34,21 +40,18 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.all() - # Lazy import — avoids a circular at app load. - from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + member_ids = resources_visible_via_memberships(self.model, user) return self.filter( - Q(created_by=user) # Owned by user - | Q(shared_users=user) # Shared with user + Q(pk__in=member_ids) # Owner or direct viewer (created_by audit-only) | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class Pipeline(DefaultOrganizationMixin, BaseModel): +class Pipeline(HasMembersMixin, DefaultOrganizationMixin, BaseModel): """Model to hold data related to Pipelines.""" class PipelineType(models.TextChoices): @@ -119,12 +122,6 @@ class PipelineStatus(models.TextChoices): blank=True, ) # Sharing fields - shared_users = models.ManyToManyField( - User, - related_name="shared_pipelines", - blank=True, - db_comment="Users with whom this pipeline is shared", - ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this pipeline is shared with the entire organization", @@ -139,6 +136,11 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in + # the polymorphic ``ResourceMembership`` table. ``created_by`` is + # audit-only; VIEWER rows succeed the former ``shared_users`` M2M. + memberships = GenericRelation("tenant_account_v2.ResourceMembership") + # Manager objects = PipelineModelManager() diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index 1f9aa6fe62..956d9d3bf7 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -30,6 +30,8 @@ class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): created_by_email = SerializerMethodField() last_5_run_statuses = SerializerMethodField() next_run_time = SerializerMethodField() + is_owner = SerializerMethodField() + co_owners_count = SerializerMethodField() # ``shared_groups`` is no longer an M2M on Pipeline — declare it # explicitly so ``fields = "__all__"`` continues to expose it. Share # mutations go through ``POST /pipeline/{id}/share/`` (UN-2977 plan §B). @@ -42,7 +44,6 @@ class Meta: # that 400s on re-save before the view can map a friendly message. validators = [] extra_kwargs = { - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -216,6 +217,13 @@ def get_created_by_email(self, obj): """Get the creator's email address.""" return obj.created_by.email if obj.created_by else None + def get_is_owner(self, obj) -> bool: + request = self.context.get("request") + return obj.is_owner(request.user) if request else False + + def get_co_owners_count(self, obj) -> int: + return obj.co_owners_count() + def get_last_5_run_statuses(self, instance: Pipeline) -> list[dict]: """Fetch the last 5 execution statuses with timestamps for this pipeline.""" return WorkflowExecution.get_last_run_statuses(instance.id, limit=5) diff --git a/backend/pipeline_v2/serializers/sharing.py b/backend/pipeline_v2/serializers/sharing.py index 43c172504b..d0426bd37e 100644 --- a/backend/pipeline_v2/serializers/sharing.py +++ b/backend/pipeline_v2/serializers/sharing.py @@ -12,6 +12,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() created_by_email = SerializerMethodField() @@ -23,19 +24,22 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", "created_by_email", ] def get_shared_users(self, obj): - """Get list of shared users with their details.""" - return UserSerializer( - obj.shared_users.filter(is_service_account=False), many=True - ).data + """Get direct viewers (VIEWER members) with their details.""" + viewers = [u for u in obj.viewers() if not u.is_service_account] + return UserSerializer(viewers, many=True).data def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return UserSerializer(obj.owners(), many=True).data + def get_created_by(self, obj): """Get the creator's username.""" return obj.created_by.username if obj.created_by else None diff --git a/backend/pipeline_v2/urls.py b/backend/pipeline_v2/urls.py index 513edd5389..11a44cdcb2 100644 --- a/backend/pipeline_v2/urls.py +++ b/backend/pipeline_v2/urls.py @@ -40,6 +40,8 @@ pipeline_execute = PipelineViewSet.as_view({"post": "execute"}) pipeline_share = PipelineViewSet.as_view({"post": "share"}) pipeline_effective_members = PipelineViewSet.as_view({"get": "effective_members"}) +pipeline_add_owner = PipelineViewSet.as_view({"post": "add_co_owner"}) +pipeline_remove_owner = PipelineViewSet.as_view({"delete": "remove_co_owner"}) urlpatterns = format_suffix_patterns( @@ -67,6 +69,16 @@ pipeline_effective_members, name="pipeline-effective-members", ), + path( + "pipeline//owners/", + pipeline_add_owner, + name="pipeline-add-owner", + ), + path( + "pipeline//owners//", + pipeline_remove_owner, + name="pipeline-remove-owner", + ), path( "pipeline/api/postman_collection//", download_postman_collection, diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py index 2cc4aab05d..f11dccdb15 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()] @@ -61,7 +80,12 @@ def get_permissions(self) -> list[Any]: def get_queryset(self) -> QuerySet: # Use for_user manager method to include shared pipelines - queryset = Pipeline.objects.for_user(self.request.user) + # Avoid per-row queries for owner/co-owner + creator fields in list views + queryset = ( + Pipeline.objects.for_user(self.request.user) + .select_related("created_by") + .prefetch_related("memberships") + ) # Apply type filter if specified pipeline_type = self.request.query_params.get(PipelineConstants.TYPE) @@ -132,6 +156,11 @@ def create(self, request: Request) -> Response: raise DuplicateData( f"{PipelineErrors.PIPELINE_EXISTS}, {PipelineErrors.DUPLICATE_API}" ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + pipeline_instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) return Response(data=serializer.data, status=status.HTTP_201_CREATED) def perform_destroy(self, instance: Pipeline) -> None: diff --git a/backend/platform_api/services.py b/backend/platform_api/services.py index 5eac5127f4..8732fff681 100644 --- a/backend/platform_api/services.py +++ b/backend/platform_api/services.py @@ -15,7 +15,7 @@ from platform_api.models import PlatformApiKey -# Business app labels whose models may have created_by / shared_users fields. +# Business app labels whose models may carry created_by / membership rows. # Restricts transfer_ownership to avoid scanning Django built-in and third-party models. _BUSINESS_APP_LABELS = { "adapter_processor_v2", @@ -73,11 +73,20 @@ def _get_user_fk_fields(model: type) -> list[str]: def _get_user_m2m_fields(model: type) -> list[str]: - """Return names of all ManyToManyField fields pointing to User.""" + """Return names of ManyToMany-to-User fields safe to mutate via ``.add()``. + + Only auto-created (implicit) through tables qualify. A custom through model + can't be mutated with ``.add()`` / ``.remove()``, so it is excluded — the + sole such data (resource memberships, UN-2202) now lives in the polymorphic + ``ResourceMembership`` table and is transferred by + :func:`_transfer_membership_rows`. + """ return [ f.name for f in model._meta.get_fields() - if isinstance(f, models.ManyToManyField) and f.related_model is User + if isinstance(f, models.ManyToManyField) + and f.related_model is User + and f.remote_field.through._meta.auto_created ] @@ -107,16 +116,37 @@ def _transfer_model_ownership(model: type, from_user: User, to_user: User) -> No getattr(instance, field_name).remove(from_user) +def _transfer_membership_rows(from_user: User, to_user: User) -> None: + """Re-point OWNER/VIEWER ``ResourceMembership`` rows across all resources. + + One polymorphic table covers every resource (UN-2202). ``unique_together + (user, content_type, object_id)`` means a resource ``to_user`` already has + a role on can't gain a second row — drop ``from_user``'s row there instead. + """ + from tenant_account_v2.models import ResourceMembership + + held = set( + ResourceMembership.objects.filter(user=to_user).values_list( + "content_type_id", "object_id" + ) + ) + for row in ResourceMembership.objects.filter(user=from_user): + if (row.content_type_id, row.object_id) in held: + row.delete() # to_user already has a role on this resource + else: + row.user = to_user + row.save(update_fields=["user"]) + + def transfer_ownership(from_user: User, to_user: User | None) -> None: """Transfer all resource ownership from one user to another. Replaces from_user with to_user across business models: - created_by / modified_by ForeignKey fields - - shared_users ManyToMany fields - - Also cleans up redundancy: if to_user becomes created_by (owner), - they are removed from shared_users on that record since ownership - already grants full access. + - auto-through ManyToMany fields to User + - OWNER/VIEWER membership rows (custom-through, UN-2202) — re-pointed with + ``unique_together`` dedup so a resource to_user already holds isn't + duplicated. """ if not to_user: return @@ -126,6 +156,8 @@ def transfer_ownership(from_user: User, to_user: User | None) -> None: if model._meta.app_label not in _BUSINESS_APP_LABELS: continue _transfer_model_ownership(model, from_user, to_user) + # Memberships live in one polymorphic table — transfer once, not per model. + _transfer_membership_rows(from_user, to_user) def delete_api_user_for_key(platform_api_key: PlatformApiKey) -> None: diff --git a/backend/prompt_studio/permission.py b/backend/prompt_studio/permission.py index d3e967e60a..6f24418988 100644 --- a/backend/prompt_studio/permission.py +++ b/backend/prompt_studio/permission.py @@ -1,6 +1,10 @@ from typing import Any -from permissions.permission import has_group_access +from permissions.permission import ( + _is_resource_owner, + _is_resource_viewer, + has_group_access, +) from rest_framework import permissions from rest_framework.request import Request from rest_framework.views import APIView @@ -11,7 +15,7 @@ class PromptAcesssToUser(permissions.BasePermission): """Is the crud to Prompt/Notes allowed to user. A user qualifies when they own the parent ``CustomTool``, are a direct - ``shared_users`` member, reach the project via group sharing + viewer (VIEWER membership, UN-2202), reach the project via group sharing (``ResourceGroupShare`` on the parent tool), or are an org admin (org-wide admin override, UN-3479). """ @@ -20,9 +24,9 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo if getattr(request.user, "is_service_account", False): return True tool = obj.tool_id - if tool.created_by == request.user: + if _is_resource_owner(request.user, tool): return True - if tool.shared_users.filter(pk=request.user.pk).exists(): + if _is_resource_viewer(request.user, tool): return True if has_group_access(request.user, tool): return True diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index 0f02cf14cd..d8d9bbb65f 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -4,7 +4,10 @@ from django.db import IntegrityError from django.db.models import QuerySet from django.http import HttpRequest -from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg +from permissions.permission import ( + IsOwnerOrSharedUserOrSharedToOrg, + IsParentToolOwner, +) from rest_framework import status, viewsets from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning @@ -26,9 +29,10 @@ class ProfileManagerView(viewsets.ModelViewSet): serializer_class = ProfileManagerSerializer def get_permissions(self) -> list[Any]: - # Mutations require ownership; reads honor sharing. + # Mutations require ownership of the parent tool (creator + co-owners); + # reads honor sharing. if self.action in ("create", "destroy", "partial_update", "update"): - return [IsOwner()] + return [IsParentToolOwner()] return [IsOwnerOrSharedUserOrSharedToOrg()] def get_queryset(self) -> QuerySet | None: diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_absorb_shared_users.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_absorb_shared_users.py new file mode 100644 index 0000000000..433a468f45 --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0009_absorb_shared_users.py @@ -0,0 +1,26 @@ +"""UN-2202: absorb custom tool creator + shared_users into ResourceMembership. + +Backfill runs before ``shared_users`` is removed, so it can still read it. +""" + +from django.db import migrations +from tenant_account_v2.migrations._membership_backfill import backfill_memberships + +APP_LABEL = "prompt_studio_core_v2" +MODEL_NAME = "CustomTool" + + +def _forward(apps, schema_editor): + backfill_memberships(apps, APP_LABEL, MODEL_NAME) + + +class Migration(migrations.Migration): + dependencies = [ + ("prompt_studio_core_v2", "0008_alter_customtool_organization"), + ("tenant_account_v2", "0005_resource_membership"), + ] + + operations = [ + migrations.RunPython(_forward, migrations.RunPython.noop), + migrations.RemoveField(model_name="customtool", name="shared_users"), + ] diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py index 7b11320098..f09a468546 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/models.py +++ b/backend/prompt_studio/prompt_studio_core_v2/models.py @@ -4,9 +4,15 @@ from account_v2.models import User from adapter_processor_v2.models import AdapterInstance +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import QuerySet +from permissions.models import HasMembersMixin from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import ( + resources_visible_via_groups, + resources_visible_via_memberships, +) from utils.file_storage.constants import FileStorageKeys from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper from utils.models.base_model import BaseModel, BaseModelManager @@ -30,16 +36,14 @@ def for_user(self, user: User) -> QuerySet[Any]: if OrganizationMemberService.is_user_organization_admin(user): return self.all() - from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + member_ids = resources_visible_via_memberships(self.model, user) return ( self.get_queryset() .filter( - models.Q(created_by=user) - | models.Q(shared_users=user) + models.Q(pk__in=member_ids) | models.Q(shared_to_org=True) | models.Q(pk__in=group_shared_ids) ) @@ -47,7 +51,7 @@ def for_user(self, user: User) -> QuerySet[Any]: ) -class CustomTool(DefaultOrganizationMixin, BaseModel): +class CustomTool(HasMembersMixin, DefaultOrganizationMixin, BaseModel): """Model to store the custom tools designed in the tool studio.""" tool_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -161,10 +165,6 @@ class CustomTool(DefaultOrganizationMixin, BaseModel): db_comment="Custom data for variable replacement in prompts using {{custom_data.key}} syntax", ) - # Introduced field to establish M2M relation between users and custom_tool. - # This will introduce intermediary table which relates both the models. - shared_users = models.ManyToManyField(User, related_name="shared_custom_tools") - # Field to enable organization-level sharing shared_to_org = models.BooleanField( default=False, @@ -189,6 +189,11 @@ def shared_groups(self): db_comment="Timestamp of the last successful export; NULL if never exported since the field was introduced.", ) + # Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in + # the polymorphic ``ResourceMembership`` table. ``created_by`` is + # audit-only; VIEWER rows succeed the former ``shared_users`` M2M. + memberships = GenericRelation("tenant_account_v2.ResourceMembership") + objects = CustomToolModelManager() def delete(self, organization_id=None, *args, **kwargs): diff --git a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py index 91515db118..4247938ff7 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py +++ b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py @@ -11,7 +11,12 @@ from adapter_processor_v2.models import AdapterInstance, UserDefaultAdapter from django.conf import settings from django.db import transaction -from permissions.permission import has_group_access +from permissions.permission import ( + _is_resource_owner, + _is_resource_viewer, + has_group_access, +) +from permissions.roles import ResourceRole from plugins import get_plugin from rest_framework.exceptions import APIException from rest_framework.request import Request @@ -88,6 +93,20 @@ logger = logging.getLogger(__name__) +def _adapter_accessible_by(adapter: AdapterInstance, user: User) -> bool: + """Whether ``user`` may use ``adapter`` (owner, direct viewer, org, or group). + + ``created_by`` is audit-only since UN-2202 — access is owner/viewer role + based via the membership bridges. + """ + return ( + adapter.shared_to_org + or _is_resource_owner(user, adapter) + or _is_resource_viewer(user, adapter) + or has_group_access(user, adapter) + ) + + class PromptStudioHelper: """Helper class for Custom tool operations.""" @@ -204,38 +223,15 @@ def validate_profile_manager_owner_access( if OrganizationMemberService.is_user_organization_admin(profile_manager_owner): return - is_llm_owned = ( - profile_manager.llm.shared_to_org - or profile_manager.llm.created_by == profile_manager_owner - or profile_manager.llm.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.llm) - ) - is_vector_store_owned = ( - profile_manager.vector_store.shared_to_org - or profile_manager.vector_store.created_by == profile_manager_owner - or profile_manager.vector_store.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.vector_store) - ) - is_embedding_model_owned = ( - profile_manager.embedding_model.shared_to_org - or profile_manager.embedding_model.created_by == profile_manager_owner - or profile_manager.embedding_model.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.embedding_model) - ) - is_x2text_owned = ( - profile_manager.x2text.shared_to_org - or profile_manager.x2text.created_by == profile_manager_owner - or profile_manager.x2text.shared_users.filter( - pk=profile_manager_owner.pk - ).exists() - or has_group_access(profile_manager_owner, profile_manager.x2text) + owner = profile_manager_owner + is_llm_owned = _adapter_accessible_by(profile_manager.llm, owner) + is_vector_store_owned = _adapter_accessible_by( + profile_manager.vector_store, owner + ) + is_embedding_model_owned = _adapter_accessible_by( + profile_manager.embedding_model, owner ) + is_x2text_owned = _adapter_accessible_by(profile_manager.x2text, owner) if not ( is_llm_owned @@ -2765,6 +2761,10 @@ def create_tool_from_import_data( organization=organization, ) + # created_by is audit-only; grant the creator an OWNER membership row so + # access/ownership flows through it (UN-2202), as the viewset create does. + tool.memberships.get_or_create(user=user, defaults={"role": ResourceRole.OWNER}) + return tool @staticmethod diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index f465d31488..c37774485a 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -43,6 +43,8 @@ class CustomToolListSerializer(serializers.ModelSerializer): created_by_email = serializers.SerializerMethodField() prompt_count = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() + co_owners_count = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -58,11 +60,20 @@ class Meta: "icon", "created_by_email", "prompt_count", + "is_owner", + "co_owners_count", ] def get_created_by_email(self, instance): return instance.created_by.email if instance.created_by else "" + def get_is_owner(self, instance) -> bool: + request = self.context.get("request") + return instance.is_owner(request.user) if request else False + + def get_co_owners_count(self, instance) -> int: + return instance.co_owners_count() + def get_prompt_count(self, instance): if hasattr(instance, "_prompt_count"): return instance._prompt_count or 0 @@ -76,9 +87,9 @@ def get_prompt_count(self, instance): class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): - # Share mutations go through ``POST /prompt-studio/{id}/share/``; - # both axes are read-only on this serializer (UN-2977 plan §B). - shared_users = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + # Share mutations go through ``POST /prompt-studio/{id}/share/``; the + # groups axis is read-only here (UN-2977 plan §B). Direct viewers live in + # the membership table (UN-2202) and surface via the share-modal serializer. shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: @@ -230,6 +241,7 @@ class SharedUserListSerializer(serializers.ModelSerializer): created_by = UserSerializer() shared_users = serializers.SerializerMethodField() shared_groups = serializers.SerializerMethodField() + co_owners = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -240,16 +252,19 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", ) def get_shared_users(self, obj): - return UserSerializer( - obj.shared_users.filter(is_service_account=False), many=True - ).data + viewers = [u for u in obj.viewers() if not u.is_service_account] + return UserSerializer(viewers, many=True).data def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return UserSerializer(obj.owners(), many=True).data + class FileInfoIdeSerializer(serializers.Serializer): document_id = serializers.CharField() diff --git a/backend/prompt_studio/prompt_studio_core_v2/urls.py b/backend/prompt_studio/prompt_studio_core_v2/urls.py index 4a453fa309..290576b299 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/urls.py +++ b/backend/prompt_studio/prompt_studio_core_v2/urls.py @@ -44,6 +44,8 @@ prompt_studio_effective_members = PromptStudioCoreView.as_view( {"get": "effective_members"} ) +prompt_studio_add_owner = PromptStudioCoreView.as_view({"post": "add_co_owner"}) +prompt_studio_remove_owner = PromptStudioCoreView.as_view({"delete": "remove_co_owner"}) prompt_studio_file = PromptStudioCoreView.as_view( @@ -148,6 +150,16 @@ prompt_studio_effective_members, name="prompt-studio-effective-members", ), + path( + "prompt-studio//owners/", + prompt_studio_add_owner, + name="prompt-studio-add-owner", + ), + path( + "prompt-studio//owners//", + prompt_studio_remove_owner, + name="prompt-studio-remove-owner", + ), path( "prompt-studio/file/", prompt_studio_file, diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 001d63a838..44488e7b48 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,13 +145,16 @@ 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()] def get_queryset(self) -> QuerySet | None: - qs = CustomTool.objects.for_user(self.request.user) + # Avoid per-row queries for owner/co-owner + creator fields in list views + qs = CustomTool.objects.for_user(self.request.user).prefetch_related( + "memberships" + ) if self.action == "list": # Subquery avoids conflict with distinct("tool_id") from for_user() prompt_count_sq = ( @@ -173,6 +188,11 @@ def create(self, request: HttpRequest) -> Response: f"{ToolStudioErrors.TOOL_NAME_EXISTS}, \ {ToolStudioErrors.DUPLICATE_API}" ) + # ``created_by`` is audit-only; the creator's access flows through an + # OWNER membership row (UN-2202 co-owners). + serializer.instance.memberships.get_or_create( + user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + ) PromptStudioHelper.create_default_profile_manager( request.user, serializer.data["tool_id"] ) diff --git a/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py b/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py index 4fee8c10bc..f46298be31 100644 --- a/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py +++ b/backend/prompt_studio/prompt_studio_registry_v2/prompt_studio_registry_helper.py @@ -206,13 +206,10 @@ def update_or_create_psr_tool( if not shared_with_org: obj.shared_users.clear() obj.shared_users.add(*user_ids) - # add prompt studio users - # for shared_user in custom_tool.shared_users: - obj.shared_users.add( - *custom_tool.shared_users.all().values_list("id", flat=True) - ) - # add prompt studio owner - obj.shared_users.add(custom_tool.created_by) + # Mirror the source tool's access: its direct viewers and its + # owners (creator + co-owners, UN-2202). + obj.shared_users.add(*[u.id for u in custom_tool.viewers()]) + obj.shared_users.add(*[u.id for u in custom_tool.owners()]) else: obj.shared_users.clear() obj.save() diff --git a/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py b/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py index 890baf2fff..dffb3f2fd4 100644 --- a/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py +++ b/backend/prompt_studio/prompt_studio_registry_v2/tests/test_models.py @@ -14,6 +14,7 @@ from account_v2.models import Organization, User from django.core.exceptions import PermissionDenied from django.test import TestCase +from permissions.roles import ResourceRole from tenant_account_v2.models import OrganizationMember from tool_instance_v2.tool_instance_helper import ToolInstanceHelper from utils.user_context import UserContext @@ -58,6 +59,8 @@ def setUp(self) -> None: created_by=self.owner, organization=self.org, ) + # Creator's access flows through an OWNER membership row (UN-2202). + self.tool.memberships.create(user=self.owner, role=ResourceRole.OWNER) # Owner-only snapshot, as written by an unshared export. self.registry = PromptStudioRegistry.objects.create( name=self.tool.tool_name, @@ -93,12 +96,14 @@ def test_org_share_after_export_makes_tool_visible(self) -> None: self.assertIn(self.registry.prompt_registry_id, self._visible_ids(self.other)) def test_user_share_after_export_makes_tool_visible(self) -> None: - self.tool.shared_users.add(self.other) + self.tool.memberships.create(user=self.other, role=ResourceRole.VIEWER) self.assertIn(self.registry.prompt_registry_id, self._visible_ids(self.other)) def test_unshare_after_share_hides_tool(self) -> None: - self.tool.shared_users.add(self.other) - self.tool.shared_users.remove(self.other) + self.tool.memberships.create(user=self.other, role=ResourceRole.VIEWER) + self.tool.memberships.filter( + user=self.other, role=ResourceRole.VIEWER + ).delete() self.assertNotIn(self.registry.prompt_registry_id, self._visible_ids(self.other)) def test_legacy_row_falls_back_to_own_share_fields(self) -> None: diff --git a/backend/tenant_account_v2/migrations/0005_resource_membership.py b/backend/tenant_account_v2/migrations/0005_resource_membership.py new file mode 100644 index 0000000000..1676605ff0 --- /dev/null +++ b/backend/tenant_account_v2/migrations/0005_resource_membership.py @@ -0,0 +1,88 @@ +# Generated by Django 4.2.1 on 2026-07-06 06:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("account_v2", "0005_alter_platformkey_organization"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("tenant_account_v2", "0004_alter_organizationgroup_organization_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ResourceMembership", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "role", + models.CharField( + choices=[("owner", "Owner"), ("viewer", "Viewer")], + default="viewer", + max_length=16, + ), + ), + ("object_id", models.CharField(max_length=255)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "organization", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="resource_memberships", + to="account_v2.organization", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resource_memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Resource Membership", + "verbose_name_plural": "Resource Memberships", + "db_table": "resource_membership", + "indexes": [ + models.Index( + fields=["content_type", "object_id", "role"], + name="resource_me_content_624443_idx", + ), + models.Index( + fields=["organization", "user"], + name="resource_me_organiz_379987_idx", + ), + ], + }, + ), + migrations.AddConstraint( + model_name="resourcemembership", + constraint=models.UniqueConstraint( + fields=("user", "content_type", "object_id"), + name="uniq_resource_membership", + ), + ), + ] diff --git a/backend/tenant_account_v2/migrations/_membership_backfill.py b/backend/tenant_account_v2/migrations/_membership_backfill.py new file mode 100644 index 0000000000..7c5da40855 --- /dev/null +++ b/backend/tenant_account_v2/migrations/_membership_backfill.py @@ -0,0 +1,70 @@ +"""Shared backfill for the UN-2202 single-table membership migration. + +Lives inside the migrations package with a ``_`` prefix so Django's migration +loader skips it (it only treats non-``_``/``~`` modules as migrations), while +the per-app membership migrations can still import it and stay thin instead of +each carrying its own copy. + +Idempotent: ``get_or_create`` is keyed on the unique ``(user, content_type, +object_id)`` triple, so re-runs and the creator-is-also-a-shared-user overlap +are both safe (the existing OWNER row wins over a would-be VIEWER row). +""" + +import logging + +logger = logging.getLogger(__name__) + +OWNER = "owner" +VIEWER = "viewer" + + +def backfill_memberships(apps, app_label: str, model_name: str) -> None: + """Absorb a resource's creator + ``shared_users`` into ``ResourceMembership``. + + Creator becomes an OWNER row, each direct ``shared_users`` entry a VIEWER + row (UN-2202). ``created_by`` is left as audit-only metadata; a null creator + is skipped (the resource has no owner and stays reachable only via + org-admin / service-account overrides). + """ + Resource = apps.get_model(app_label, model_name) # NOSONAR + Membership = apps.get_model("tenant_account_v2", "ResourceMembership") # NOSONAR + ContentType = apps.get_model("contenttypes", "ContentType") # NOSONAR + + content_type = ContentType.objects.get_for_model(Resource) + owners = viewers = skipped = skipped_org = 0 + for resource in Resource.objects.iterator(): + # A NULL-org resource can't carry tenant-scoped membership rows (the + # organization FK is NOT NULL) — skip it rather than abort the migration. + if resource.organization_id is None: + skipped_org += 1 + continue + object_id = str(resource.pk) + if resource.created_by_id: + _, created = Membership.objects.get_or_create( + content_type=content_type, + object_id=object_id, + user_id=resource.created_by_id, + defaults={"role": OWNER, "organization_id": resource.organization_id}, + ) + owners += int(created) + else: + skipped += 1 + for user_id in resource.shared_users.values_list("id", flat=True): + _, created = Membership.objects.get_or_create( + content_type=content_type, + object_id=object_id, + user_id=user_id, + defaults={"role": VIEWER, "organization_id": resource.organization_id}, + ) + viewers += int(created) + + logger.info( + "%s.%s memberships backfilled: owners=%s viewers=%s " + "(skipped %s null-creator, %s null-org)", + app_label, + model_name, + owners, + viewers, + skipped, + skipped_org, + ) diff --git a/backend/tenant_account_v2/models.py b/backend/tenant_account_v2/models.py index e6c4aa5667..f569f50f6e 100644 --- a/backend/tenant_account_v2/models.py +++ b/backend/tenant_account_v2/models.py @@ -1,6 +1,7 @@ from account_v2.models import Organization, User from django.contrib.contenttypes.models import ContentType from django.db import models +from permissions.roles import ResourceRole from utils.models.base_model import BaseModel from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -133,6 +134,74 @@ class Meta: ] +class ResourceMembership(BaseModel): + """Polymorphic per-user role on a shareable resource. + + One table for every resource type — the membership analogue of + :class:`ResourceGroupShare`. Each resource exposes its rows through a + ``GenericRelation`` named ``memberships``. ``OWNER`` ≈ creator / co-owner + (full control); ``VIEWER`` ≈ direct shared user (read / use — the + successor to the old per-resource ``shared_users`` M2M). The resource's + ``created_by`` is audit-only. ``organization`` is stored for tenant-scoped + purge / transfer and is derived from the resource on insert (see + :meth:`save`), so no create site has to remember it. + """ + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="resource_memberships", + ) + role = models.CharField( + max_length=16, + choices=ResourceRole.choices, + default=ResourceRole.VIEWER, + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + # Resource PK as text — every in-scope resource uses UUID PKs, but the + # column stays varchar to mirror ``ResourceGroupShare`` and stay open to + # future non-UUID resources. + object_id = models.CharField(max_length=255) + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="resource_memberships", + # Server-managed; derived from the resource, never client input. + editable=False, + ) + + class Meta: + db_table = "resource_membership" + verbose_name = "Resource Membership" + verbose_name_plural = "Resource Memberships" + constraints = [ + models.UniqueConstraint( + fields=["user", "content_type", "object_id"], + name="uniq_resource_membership", + ), + ] + indexes = [ + models.Index(fields=["content_type", "object_id", "role"]), + models.Index(fields=["organization", "user"]), + ] + + def save(self, *args: object, **kwargs: object) -> None: + # Derive ``organization`` from the resource so no create site (share + # action, backfill, transfer) has to pass it — a null org would break + # the tenant-scoped VIEWER purge in ``cleanup_user_org_access``. + # ``_base_manager`` bypasses the resource's org-scoped default manager, + # which returns nothing outside a request (worker / management paths). + if self.organization_id is None and self.content_type_id and self.object_id: + model = self.content_type.model_class() + if model is not None: + self.organization_id = ( + model._base_manager.filter(pk=self.object_id) + .values_list("organization_id", flat=True) + .first() + ) + super().save(*args, **kwargs) + + class GroupMembership(BaseModel): """Explicit through model for OrganizationGroup membership. diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py index 6edbd39ae3..7de5ac85d8 100644 --- a/backend/tenant_account_v2/sharing_helpers.py +++ b/backend/tenant_account_v2/sharing_helpers.py @@ -41,6 +41,7 @@ OrganizationGroup, OrganizationMember, ResourceGroupShare, + ResourceMembership, ) logger = logging.getLogger(__name__) @@ -202,6 +203,25 @@ def resources_visible_via_groups( return list(raw_ids) +def resources_visible_via_memberships(model: type[Model], user: Any) -> list[Any]: + """PKs of ``model`` rows on which ``user`` holds a ``ResourceMembership`` + (OWNER or VIEWER). + + Per-user analogue of :func:`resources_visible_via_groups`. + ``ResourceMembership.object_id`` is varchar, so a ``memberships__user`` JOIN + makes Postgres refuse the implicit ``uuid = character varying`` comparison; + we materialise the ids and cast in Python instead. Bounded by the rows the + user is a member of. + """ + raw_ids = ResourceMembership.objects.filter( + content_type=ContentType.objects.get_for_model(model), + user=user, + ).values_list("object_id", flat=True) + if isinstance(model._meta.pk, models.UUIDField): + return _safe_uuids(raw_ids) + return list(raw_ids) + + def serialize_group_refs(resource_obj: Any) -> list[dict[str, Any]]: """Return a compact ``[{id, name}]`` listing for share modals.""" return list(get_resource_share_groups(resource_obj).values("id", "name")) @@ -218,17 +238,17 @@ def compute_effective_members(resource_obj: Any) -> list[dict[str, Any]]: """ seen: dict[int, dict[str, Any]] = {} - # Direct shares - direct_users = list( - resource_obj.shared_users.filter(is_service_account=False).values( - "id", "email", "first_name", "last_name" - ) - ) - for u in direct_users: - seen[u["id"]] = { - "user_id": u["id"], - "email": u["email"], - "display_name": _display_name(u), + # Direct shares — VIEWER membership rows (UN-2202 Phase 2 successor to the + # old ``shared_users`` M2M). Owners are excluded here: they hold the + # resource, they are not "shared with" it (parity with prior behavior). + for membership in resource_obj.viewer_memberships(): + user = membership.user + if getattr(user, "is_service_account", False): + continue + seen[user.id] = { + "user_id": user.id, + "email": user.email, + "display_name": _user_display_name(user), "access_via": "direct", "group_id": None, "group_name": None, @@ -285,15 +305,6 @@ def _add_org_members(seen: dict[int, dict[str, Any]], resource_obj: Any) -> None } -def _display_name(user_dict: dict[str, Any]) -> str: - parts = [ - (user_dict.get("first_name") or "").strip(), - (user_dict.get("last_name") or "").strip(), - ] - full = " ".join(p for p in parts if p) - return full or user_dict.get("email") or "" - - def _user_display_name(user: User) -> str: full = (user.get_full_name() or "").strip() return full or user.email @@ -371,7 +382,9 @@ def authorize_and_commit( cls._commit(resource, desired) return - is_owner = resource.created_by_id == actor.pk + # Ownership is role-based (OWNER membership), not ``created_by``, since + # UN-2202 — ``created_by`` is audit-only. + is_owner = resource.is_owner(actor) is_admin = is_org_admin(actor) cls._authorize(actor, resource, desired, is_owner, is_admin) cls._commit(resource, desired) @@ -533,17 +546,30 @@ def _diff_id_axis( @staticmethod def _current_ids(resource: Any, axis: str) -> set[int]: + if axis == ShareAuthorizationService.USERS_AXIS: + return ShareAuthorizationService._current_viewer_ids(resource) if axis == ShareAuthorizationService.GROUPS_AXIS: return set(get_resource_share_groups(resource).values_list("id", flat=True)) return set(getattr(resource, axis).values_list("pk", flat=True)) + @staticmethod + def _current_viewer_ids(resource: Any) -> set[int]: + """User ids holding a VIEWER membership row on ``resource``.""" + from permissions.roles import ResourceRole + + return set( + resource.memberships.filter(role=ResourceRole.VIEWER).values_list( + "user_id", flat=True + ) + ) + # ----------------------------------------------------------------- write @classmethod @transaction.atomic def _commit(cls, resource: Any, desired: dict[str, Any]) -> None: if cls.USERS_AXIS in desired: - getattr(resource, cls.USERS_AXIS).set(desired[cls.USERS_AXIS] or []) + cls._set_viewer_members(resource, desired[cls.USERS_AXIS] or []) if cls.GROUPS_AXIS in desired: set_resource_share_groups(resource, desired[cls.GROUPS_AXIS] or []) if cls.ORG_AXIS in desired: @@ -552,6 +578,31 @@ def _commit(cls, resource: Any, desired: dict[str, Any]) -> None: setattr(resource, cls.ORG_AXIS, new_value) resource.save(update_fields=[cls.ORG_AXIS, "modified_at"]) + @staticmethod + def _set_viewer_members(resource: Any, desired_ids: Iterable[int]) -> None: + """Replace ``resource``'s VIEWER membership rows to match ``desired_ids``. + + Mirrors M2M ``.set()`` semantics over the membership table. OWNER rows + are never touched: a desired id that is already an OWNER is left as-is + (``unique_together`` forbids a second row), so owners are never demoted + or duplicated (UN-2202 Phase 2). + """ + from permissions.roles import ResourceRole + + desired = {int(pk) for pk in desired_ids or ()} + memberships = resource.memberships + current_viewer_ids = set( + memberships.filter(role=ResourceRole.VIEWER).values_list("user_id", flat=True) + ) + to_remove = current_viewer_ids - desired + if to_remove: + memberships.filter(role=ResourceRole.VIEWER, user_id__in=to_remove).delete() + for user_id in desired - current_viewer_ids: + # get_or_create: an existing OWNER row is returned untouched. + memberships.get_or_create( + user_id=user_id, defaults={"role": ResourceRole.VIEWER} + ) + # ------------------------------------------------------------ exceptions @staticmethod diff --git a/backend/tenant_account_v2/signals.py b/backend/tenant_account_v2/signals.py index a705a04777..db95524b7f 100644 --- a/backend/tenant_account_v2/signals.py +++ b/backend/tenant_account_v2/signals.py @@ -1,13 +1,16 @@ import logging from django.apps import apps -from django.core.exceptions import FieldDoesNotExist from django.db import transaction -from django.db.models.fields.related import ManyToManyRel from django.db.models.signals import post_delete from django.dispatch import receiver +from permissions.roles import ResourceRole -from tenant_account_v2.models import GroupMembership, OrganizationMember +from tenant_account_v2.models import ( + GroupMembership, + OrganizationMember, + ResourceMembership, +) from tenant_account_v2.shareable_resources import SHAREABLE_RESOURCES logger = logging.getLogger(__name__) @@ -22,13 +25,15 @@ def cleanup_user_org_access( Two cleanups: 1. Group memberships for that org (group-derived access goes away live via ``for_user()``). - 2. Direct ``shared_users`` M2M entries on every shareable resource of - that org — closes the rejoin backdoor where a re-invited user would - silently regain direct access. + 2. Direct VIEWER membership rows on every shareable resource of that org + — closes the rejoin backdoor where a re-invited user would silently + regain direct access. OWNER rows are left intact (parity with the old + ``shared_users``-only purge, where ``created_by`` ownership survived + re-invite); a departing owner's resources stay admin-manageable. Uses a signal (not DB CASCADE) so notification / audit hooks can attach here later without a schema change. The whole purge runs in one - transaction so a mid-loop failure rolls back rather than leaving the user + transaction so a mid-step failure rolls back rather than leaving the user partially purged (which would silently re-open the rejoin backdoor). """ with transaction.atomic(): @@ -38,59 +43,28 @@ def cleanup_user_org_access( ).delete() if deleted_count: logger.info( - "Removed %s group memberships for user=%s org=%s after OrganizationMember delete", + "Removed %s group memberships for user=%s org=%s after " + "OrganizationMember delete", deleted_count, instance.user_id, instance.organization_id, ) - for resource in SHAREABLE_RESOURCES: - try: - model = apps.get_model(resource.app_label, resource.model_name) - except LookupError: - # App not installed in this deployment (e.g. cloud-only - # agentic_studio_v1 in pure OSS). Skip cleanly. - continue - # Delete via the M2M through table, not ``model.objects``: the - # default manager is org-scoped on ``UserContext`` (None outside - # an HTTP request), so it would match zero rows in tests / - # management commands. The through manager is unscoped; scope it - # explicitly by the resource's own organization. - try: - m2m_rel = model._meta.get_field("shared_users").remote_field - except FieldDoesNotExist: - # A registered model can legitimately lack the sharing field - # during the OSS<->cloud sync window (e.g. AgenticProject - # before #1508 applies its migration). Group memberships were - # already purged above; skip the direct-share purge here. - continue - assert isinstance(m2m_rel, ManyToManyRel) - through = m2m_rel.through - source_fk = model._meta.model_name - try: - removed, _ = through.objects.filter( - user=instance.user, - **{f"{source_fk}__organization": instance.organization}, - ).delete() - except Exception: - logger.exception( - "Failed purging shared_users for user=%s on %s.%s org=%s; " - "rolling back the whole purge", - instance.user_id, - resource.app_label, - resource.model_name, - instance.organization_id, - ) - raise - if removed: - logger.info( - "Removed user=%s from shared_users on %s %s.%s rows in org=%s", - instance.user_id, - removed, - resource.app_label, - resource.model_name, - instance.organization_id, - ) + # One polymorphic table + explicit ``organization`` FK — a single + # query purges direct VIEWER access across every shareable resource, + # unscoped by ``UserContext`` (None outside an HTTP request). + removed, _ = ResourceMembership.objects.filter( + user=instance.user, + organization=instance.organization, + role=ResourceRole.VIEWER, + ).delete() + if removed: + logger.info( + "Removed %s VIEWER memberships for user=%s in org=%s", + removed, + instance.user_id, + instance.organization_id, + ) def cleanup_resource_group_shares( diff --git a/backend/tenant_account_v2/tests.py b/backend/tenant_account_v2/tests.py index e6b0f70229..9c36386366 100644 --- a/backend/tenant_account_v2/tests.py +++ b/backend/tenant_account_v2/tests.py @@ -17,6 +17,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.test import TestCase +from permissions.roles import ResourceRole from rest_framework.exceptions import PermissionDenied from rest_framework.test import APIRequestFactory, force_authenticate from utils.user_context import UserContext @@ -49,7 +50,20 @@ def _shared_group_ids(resource) -> set[int]: def _shared_user_ids(resource) -> set[int]: - return set(resource.shared_users.values_list("id", flat=True)) + """Direct-viewer user ids (VIEWER memberships, UN-2202).""" + return set( + resource.memberships.filter(role=ResourceRole.VIEWER).values_list( + "user_id", flat=True + ) + ) + + +def _add_viewers(resource, *users) -> None: + """Add direct viewers as VIEWER rows (successor to ``shared_users.add``).""" + for user in users: + resource.memberships.get_or_create( + user=user, defaults={"role": ResourceRole.VIEWER} + ) class GroupSharingTestBase(TestCase): @@ -81,6 +95,8 @@ def setUp(self) -> None: self.workflow = Workflow.objects.create( workflow_name="wf-1", organization=self.org, created_by=self.owner ) + # Creator's access flows through an OWNER membership row (UN-2202). + self.workflow.memberships.create(user=self.owner, role=ResourceRole.OWNER) class ShareAuthorizationServiceTests(GroupSharingTestBase): @@ -116,12 +132,12 @@ def test_owner_can_add_group_and_toggle_org(self) -> None: self.assertTrue(self.workflow.shared_to_org) def test_admin_can_remove_users(self) -> None: - self.workflow.shared_users.add(self.member) + _add_viewers(self.workflow, self.member) self._authorize(self.admin, {"shared_users": []}) self.assertEqual(_shared_user_ids(self.workflow), set()) def test_unprivileged_cannot_remove_users(self) -> None: - self.workflow.shared_users.add(self.member, self.outsider) + _add_viewers(self.workflow, self.member, self.outsider) with self.assertRaises(PermissionDenied): # outsider (a shared user, not owner/admin) tries to drop member self._authorize(self.outsider, {"shared_users": [self.outsider.id]}) @@ -156,7 +172,7 @@ def test_service_account_bypasses_authorization(self) -> None: def test_authorize_is_atomic_on_partial_denial(self) -> None: """A denial on any axis must leave every axis uncommitted.""" - self.workflow.shared_users.add(self.outsider) + _add_viewers(self.workflow, self.outsider) with self.assertRaises(PermissionDenied): # users add is allowed for a shared user, but the org toggle isn't self._authorize( @@ -229,7 +245,7 @@ class SignalCleanupTests(GroupSharingTestBase): """The two ``post_delete`` cleanups: rejoin-backdoor and orphan prevention.""" def test_org_member_removal_purges_memberships_and_direct_shares(self) -> None: - self.workflow.shared_users.add(self.member) + _add_viewers(self.workflow, self.member) set_resource_share_groups(self.workflow, [self.group.id]) OrganizationMember.objects.get(user=self.member).delete() diff --git a/backend/tool_instance_v2/tool_instance_helper.py b/backend/tool_instance_v2/tool_instance_helper.py index f873b0d8bc..a9c126dac4 100644 --- a/backend/tool_instance_v2/tool_instance_helper.py +++ b/backend/tool_instance_v2/tool_instance_helper.py @@ -9,7 +9,11 @@ from django.core.exceptions import PermissionDenied from django.core.exceptions import ValidationError as DjangoValidationError from jsonschema.exceptions import ValidationError as JSONValidationError -from permissions.permission import has_group_access +from permissions.permission import ( + _is_resource_owner, + _is_resource_viewer, + has_group_access, +) from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry from tenant_account_v2.organization_member_service import OrganizationMemberService from workflow_manager.workflow_v2.constants import WorkflowKey @@ -507,8 +511,8 @@ def validate_adapter_access( if not ( is_admin or adapter_instance.shared_to_org - or adapter_instance.created_by == user - or adapter_instance.shared_users.filter(pk=user.pk).exists() + or _is_resource_owner(user, adapter_instance) + or _is_resource_viewer(user, adapter_instance) or has_group_access(user, adapter_instance) ): logger.error( diff --git a/backend/workflow_manager/workflow_v2/migrations/0022_absorb_shared_users.py b/backend/workflow_manager/workflow_v2/migrations/0022_absorb_shared_users.py new file mode 100644 index 0000000000..7a5587dadc --- /dev/null +++ b/backend/workflow_manager/workflow_v2/migrations/0022_absorb_shared_users.py @@ -0,0 +1,26 @@ +"""UN-2202: absorb workflow creator + shared_users into ResourceMembership. + +Backfill runs before ``shared_users`` is removed, so it can still read it. +""" + +from django.db import migrations +from tenant_account_v2.migrations._membership_backfill import backfill_memberships + +APP_LABEL = "workflow_v2" +MODEL_NAME = "Workflow" + + +def _forward(apps, schema_editor): + backfill_memberships(apps, APP_LABEL, MODEL_NAME) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow_v2", "0021_alter_workflow_organization"), + ("tenant_account_v2", "0005_resource_membership"), + ] + + operations = [ + migrations.RunPython(_forward, migrations.RunPython.noop), + migrations.RemoveField(model_name="workflow", name="shared_users"), + ] diff --git a/backend/workflow_manager/workflow_v2/models/execution.py b/backend/workflow_manager/workflow_v2/models/execution.py index e59e8faebb..957f55e74d 100644 --- a/backend/workflow_manager/workflow_v2/models/execution.py +++ b/backend/workflow_manager/workflow_v2/models/execution.py @@ -9,6 +9,7 @@ from pipeline_v2.models import Pipeline from tags.models import Tag from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import resources_visible_via_memberships from usage_v2.constants import UsageKeys from usage_v2.helper import UsageHelper from usage_v2.models import Usage @@ -63,25 +64,22 @@ def for_user(self, user) -> QuerySet: return self.filter(workflow__organization=org) return self.all() - # Filter for workflow access - workflow_filter = Q(workflow__created_by=user) | Q(workflow__shared_users=user) + # Filter for workflow access (owner or direct viewer via membership). + # ``created_by`` is audit-only (UN-2202); VIEWER rows replaced shared_users. + # ``object_id`` is varchar, so resolve the ids via the cast helper rather + # than a ``memberships`` JOIN (Postgres refuses ``uuid = varchar``). + workflow_filter = Q( + workflow_id__in=resources_visible_via_memberships(Workflow, user) + ) # Filter for API deployments the user can access api_filter = Q( - pipeline_id__in=models.Subquery( - APIDeployment.objects.filter( - Q(created_by=user) | Q(shared_users=user) - ).values("id") - ) + pipeline_id__in=resources_visible_via_memberships(APIDeployment, user) ) # Filter for Pipelines the user can access pipeline_filter = Q( - pipeline_id__in=models.Subquery( - Pipeline.objects.filter(Q(created_by=user) | Q(shared_users=user)).values( - "id" - ) - ) + pipeline_id__in=resources_visible_via_memberships(Pipeline, user) ) # Combine deployment filters diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py index 1b2891937e..461e63689b 100644 --- a/backend/workflow_manager/workflow_v2/models/workflow.py +++ b/backend/workflow_manager/workflow_v2/models/workflow.py @@ -2,10 +2,16 @@ from account_v2.models import User from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q +from permissions.models import HasMembersMixin from tenant_account_v2.organization_member_service import OrganizationMemberService +from tenant_account_v2.sharing_helpers import ( + resources_visible_via_groups, + resources_visible_via_memberships, +) from utils.models.base_model import BaseModel, BaseModelManager from utils.models.organization_mixin import ( DefaultOrganizationManagerMixin, @@ -32,19 +38,17 @@ def for_user(self, user): if OrganizationMemberService.is_user_organization_admin(user): return self.all() - from tenant_account_v2.sharing_helpers import resources_visible_via_groups - user_group_ids = user.group_memberships.values_list("group_id", flat=True) group_shared_ids = resources_visible_via_groups(self.model, user_group_ids) + member_ids = resources_visible_via_memberships(self.model, user) return self.filter( - Q(created_by=user) # Owned by user - | Q(shared_users=user) # Shared with user + Q(pk__in=member_ids) # Owner or direct viewer (created_by audit-only) | Q(shared_to_org=True) # Shared to entire organization | Q(pk__in=group_shared_ids) # Shared via group membership ).distinct() -class Workflow(DefaultOrganizationMixin, BaseModel): +class Workflow(HasMembersMixin, DefaultOrganizationMixin, BaseModel): class WorkflowType(models.TextChoices): DEFAULT = "DEFAULT", "Not ready yet" ETL = "ETL", "ETL pipeline" @@ -105,9 +109,6 @@ class ExecutionAction(models.TextChoices): ) # Sharing fields - shared_users = models.ManyToManyField( - User, related_name="shared_workflows", blank=True - ) shared_to_org = models.BooleanField( default=False, db_comment="Whether this workflow is shared with the entire organization", @@ -122,6 +123,11 @@ def shared_groups(self): return get_resource_share_groups(self) + # Owner + direct-viewer access lives here (UN-2202): OWNER / VIEWER rows in + # the polymorphic ``ResourceMembership`` table. ``created_by`` is + # audit-only; VIEWER rows succeed the former ``shared_users`` M2M. + memberships = GenericRelation("tenant_account_v2.ResourceMembership") + # Manager objects = WorkflowModelManager() diff --git a/backend/workflow_manager/workflow_v2/permissions.py b/backend/workflow_manager/workflow_v2/permissions.py index 3d957b6ea5..01e0cb0ca0 100644 --- a/backend/workflow_manager/workflow_v2/permissions.py +++ b/backend/workflow_manager/workflow_v2/permissions.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from permissions.permission import _is_resource_owner, _is_resource_viewer from rest_framework.permissions import BasePermission from tenant_account_v2.organization_member_service import OrganizationMemberService @@ -9,9 +10,9 @@ class IsWorkflowOwnerOrShared(BasePermission): """Permission class to check if user has access to a workflow. Checks: - 1. User is the workflow owner (created_by) - 2. User has shared access (in shared_users) - 3. Workflow is shared to user's organization (shared_to_org) + 1. User owns the workflow (OWNER membership; ``created_by`` is audit-only). + 2. User is a direct viewer (VIEWER membership). + 3. Workflow is shared to user's organization (shared_to_org). Caches the workflow on request object to avoid duplicate fetching. """ @@ -29,7 +30,7 @@ def has_permission(self, request, view): request._workflow_cache = get_object_or_404( Workflow.objects.select_related( "created_by", "organization" - ).prefetch_related("shared_users"), + ).prefetch_related("memberships__user"), id=workflow_id, ) @@ -37,8 +38,8 @@ def has_permission(self, request, view): user = request.user has_access = ( - workflow.created_by == user - or user in workflow.shared_users.all() + _is_resource_owner(user, workflow) + or _is_resource_viewer(user, workflow) or (workflow.shared_to_org and workflow.organization == user.organization) or OrganizationMemberService.is_user_organization_admin(user) ) diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index 47edf54d66..ad7eda543e 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -48,7 +48,6 @@ class Meta: WorkflowKey.LLM_RESPONSE: { "required": False, }, - "shared_users": {"read_only": True}, "shared_to_org": {"read_only": True}, } @@ -80,6 +79,9 @@ def to_representation(self, instance: Workflow) -> dict[str, str]: representation["created_by_email"] = ( instance.created_by.email if instance.created_by else None ) + request = self.context.get("request") + representation["is_owner"] = instance.is_owner(request.user) if request else False + representation["co_owners_count"] = instance.co_owners_count() return representation def create(self, validated_data: dict[str, Any]) -> Any: @@ -187,6 +189,7 @@ class SharedUserListSerializer(ModelSerializer): shared_users = SerializerMethodField() shared_groups = SerializerMethodField() + co_owners = SerializerMethodField() created_by = SerializerMethodField() class Meta: @@ -197,19 +200,24 @@ class Meta: "shared_users", "shared_to_org", "shared_groups", + "co_owners", "created_by", ] def get_shared_users(self, obj): - """Return list of shared users with id and email.""" + """Return direct viewers (VIEWER members) with id and email.""" return [ {"id": user.id, "email": user.email} - for user in obj.shared_users.filter(is_service_account=False) + for user in obj.viewers() + if not user.is_service_account ] def get_shared_groups(self, obj): return serialize_group_refs(obj) + def get_co_owners(self, obj): + return [{"id": u.id, "email": u.email} for u in obj.owners()] + def get_created_by(self, obj): """Return creator details.""" if obj.created_by: diff --git a/backend/workflow_manager/workflow_v2/urls/workflow.py b/backend/workflow_manager/workflow_v2/urls/workflow.py index 1225a4a32c..7eee26821e 100644 --- a/backend/workflow_manager/workflow_v2/urls/workflow.py +++ b/backend/workflow_manager/workflow_v2/urls/workflow.py @@ -28,6 +28,8 @@ list_shared_users = WorkflowViewSet.as_view({"get": "list_of_shared_users"}) workflow_share = WorkflowViewSet.as_view({"post": "share"}) workflow_effective_members = WorkflowViewSet.as_view({"get": "effective_members"}) +workflow_add_owner = WorkflowViewSet.as_view({"post": "add_co_owner"}) +workflow_remove_owner = WorkflowViewSet.as_view({"delete": "remove_co_owner"}) # File History views file_history_list = FileHistoryViewSet.as_view({"get": "list"}) @@ -59,6 +61,12 @@ workflow_effective_members, name="workflow-effective-members", ), + path("/owners/", workflow_add_owner, name="workflow-add-owner"), + path( + "/owners//", + workflow_remove_owner, + name="workflow-remove-owner", + ), path("execute/", workflow_execute, name="execute-workflow"), path( "active//", diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index e0822e966f..2753a26768 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()] @@ -92,6 +108,8 @@ def get_queryset(self) -> QuerySet: if filter_args else Workflow.objects.for_user(self.request.user) ) + # Avoid per-row queries for owner/co-owner + creator fields in list views + queryset = queryset.select_related("created_by").prefetch_related("memberships") order_by = self.request.query_params.get("order_by") if order_by == "desc": queryset = queryset.order_by("-modified_at") @@ -129,6 +147,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 ? ( + + ) : ( + <> +