diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index f2aef82b0c..79092acadf 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -15,7 +15,6 @@ 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 @@ -271,9 +270,7 @@ def create(self, request: Any) -> Response: 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} - ) + instance.grant_owner(request.user) 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 46ddc67953..33d7844b9d 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -9,7 +9,6 @@ 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 platform_api.openapi_schema import PlatformKeyAutoSchema from plugins import get_plugin from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry @@ -360,9 +359,7 @@ def create( 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} - ) + serializer.instance.grant_owner(request.user) api_key = DeploymentHelper.create_api_key(serializer=serializer, request=request) response_serializer = DeploymentResponseSerializer( {"api_key": api_key.api_key, **serializer.data} @@ -406,8 +403,11 @@ def by_prompt_studio_tool(self, request: Request) -> Response: # Get API deployments for these workflows the user can access — # ``created_by`` is audit-only; access flows through memberships, # sharing, and the admin/SA bypasses (UN-2202). - deployments = APIDeployment.objects.for_user(request.user).filter( - workflow_id__in=workflow_ids + deployments = ( + APIDeployment.objects.for_user(request.user) + .select_related("created_by") + .prefetch_related("memberships__user") + .filter(workflow_id__in=workflow_ids) ) serializer = APIDeploymentListSerializer(deployments, many=True) diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 31c609ebaa..d9d8c9638a 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -510,6 +510,7 @@ class APIDeploymentListSerializer(ModelSerializer): last_run_time = SerializerMethodField() is_owner = SerializerMethodField() co_owners_count = SerializerMethodField() + owner_emails = SerializerMethodField() class Meta: model = APIDeployment @@ -529,6 +530,7 @@ class Meta: "last_run_time", "is_owner", "co_owners_count", + "owner_emails", ] def get_created_by_email(self, obj) -> str | None: @@ -542,6 +544,12 @@ def get_is_owner(self, obj) -> bool: def get_co_owners_count(self, obj) -> int: return obj.co_owners_count() + def get_owner_emails(self, obj) -> list[str]: + """Email of each owner, earliest first. Empty if none is a person.""" + # Published field: APIDeploymentSummary inherits it, so it also + # reaches platform-key callers. + return obj.owner_emails() + # Both read the list view's annotations when they are there, and fall back # to a query for the callers that serialize a plain queryset. A deployment # that has never run annotates to `None`, so absence is what decides, not diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index fd75b749db..28f8038213 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -11,7 +11,6 @@ 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 @@ -258,9 +257,7 @@ def create(self, request: Any) -> Response: ) # ``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} - ) + serializer.instance.grant_owner(request.user) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/backend/permissions/models.py b/backend/permissions/models.py index 7f5ad12087..80b2d5030b 100644 --- a/backend/permissions/models.py +++ b/backend/permissions/models.py @@ -76,3 +76,12 @@ def is_owner(self, user: Any) -> bool: m.user_id == user.id and m.role == ResourceRole.OWNER for m in self.memberships.all() # type: ignore[attr-defined] ) + + def grant_owner(self, user: Any) -> None: + """Grant OWNER on create, resolving a platform key to its creator.""" + # Lazy: platform_api.services imports models at import time. + from platform_api.services import owner_user_for + + self.memberships.get_or_create( # type: ignore[attr-defined] + user=owner_user_for(user), defaults={"role": ResourceRole.OWNER} + ) diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 33d4dd5079..18db6827ad 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -282,6 +282,8 @@ def has_object_permission( ) -> bool: if obj.is_friction_less: return True + if _is_service_account(request): + return True if _is_resource_owner(request.user, obj): return True return _is_organization_admin(request) diff --git a/backend/pipeline_v2/serializers/crud.py b/backend/pipeline_v2/serializers/crud.py index 956d9d3bf7..acc45a3336 100644 --- a/backend/pipeline_v2/serializers/crud.py +++ b/backend/pipeline_v2/serializers/crud.py @@ -32,6 +32,7 @@ class PipelineSerializer(IntegrityErrorMixin, AuditSerializer): next_run_time = SerializerMethodField() is_owner = SerializerMethodField() co_owners_count = SerializerMethodField() + owner_emails = 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). @@ -224,6 +225,11 @@ def get_is_owner(self, obj) -> bool: def get_co_owners_count(self, obj) -> int: return obj.co_owners_count() + def get_owner_emails(self, obj) -> list[str]: + # Names the actual owner in "Owned By"; ``created_by`` is audit-only + # (UN-2202) and stays the service account on platform-key creates. + return obj.owner_emails() + 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/views.py b/backend/pipeline_v2/views.py index ec7e7720f3..2bf502c904 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -12,7 +12,6 @@ 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 @@ -158,9 +157,7 @@ def create(self, request: Request) -> Response: pipeline_instance = serializer.save() # Grant before the API key so the creator's access is committed # with the row itself, matching api_deployment_views.create(). - pipeline_instance.memberships.get_or_create( - user_id=request.user.id, defaults={"role": ResourceRole.OWNER} - ) + pipeline_instance.grant_owner(request.user) # Create API key using the created instance KeyHelper.create_api_key(pipeline_instance, request) except IntegrityError: diff --git a/backend/platform_api/services.py b/backend/platform_api/services.py index 4087741227..0fce45cb32 100644 --- a/backend/platform_api/services.py +++ b/backend/platform_api/services.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import re import uuid as _uuid from typing import TYPE_CHECKING @@ -15,6 +16,15 @@ from platform_api.models import PlatformApiKey +logger = logging.getLogger(__name__) + +# Distinguishes "caller said nothing" from an explicit None in transfer_ownership. +_SAME_AS_TO_USER: object = object() + +# Reserved domain for service-account addresses. The frontend matches on it to +# label an ownerless resource "Platform key" instead of naming a machine. +SERVICE_ACCOUNT_EMAIL_DOMAIN = "platform.internal" + # 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 = { @@ -45,7 +55,7 @@ def create_api_user_for_key( name_slug = _slugify_for_email(platform_api_key.name) user = User( username=f"svc-{name_slug}-{uid[:8]}", - email=f"{name_slug}-{uid[:8]}@platform.internal", + email=f"{name_slug}-{uid[:8]}@{SERVICE_ACCOUNT_EMAIL_DOMAIN}", user_id=uid, is_service_account=True, ) @@ -63,6 +73,63 @@ def create_api_user_for_key( return user +def live_key_creator(platform_api_key: PlatformApiKey) -> User | None: + """The key's creator if they still belong to the key's organization. + + ``_is_resource_owner`` grants on any surviving OWNER row without checking + live membership, which is why ``cleanup_user_org_access`` purges those rows + when a user leaves. Handing an ex-member a fresh row -- at create, on key + deletion, or in a backfill -- reopens that rejoin backdoor, so every path + that names a successor asks this one question. + """ + creator = platform_api_key.created_by + if creator is None: + return None + # ``_base_manager`` because the default manager is org-scoped by + # ``UserContext``, which is None outside a request — an empty result would + # silently strip every resource of its owner. The org is filtered here. + if not OrganizationMember._base_manager.filter( + user=creator, organization=platform_api_key.organization + ).exists(): + return None + return creator + + +def owner_user_for(user: User) -> User: + """Resolve the human who should own a resource created by ``user``. + + Service accounts are filtered out of every owner surface, so granting to + one leaves no human owner; attribute it to the key's live creator instead. + Returns ``user`` unchanged for a normal session, or when no live creator + can be named -- the UI then labels the resource "Platform key". + """ + if not getattr(user, "is_service_account", False): + return user + + # Imported here so the module keeps its models import behind TYPE_CHECKING. + from platform_api.models import PlatformApiKey + + key = ( + PlatformApiKey.objects.filter(api_user=user) + .select_related("created_by", "organization") + .first() + ) + if key is None: + logger.warning( + "Service account %s backs no platform key; resource gets no human owner", + user.id, + ) + return user + creator = live_key_creator(key) + if creator is None: + logger.warning( + "Platform key %s has no live creator; resource gets no human owner", + key.id, + ) + return user + return creator + + def _get_user_fk_fields(model: type) -> list[str]: """Return names of all ForeignKey fields pointing to User.""" return [ @@ -150,7 +217,9 @@ def _transfer_membership_rows(from_user: User, to_user: User) -> None: row.delete() -def transfer_ownership(from_user: User, to_user: User | None) -> None: +def transfer_ownership( + from_user: User, to_user: User | None, membership_to: User | None = _SAME_AS_TO_USER +) -> None: """Transfer all resource ownership from one user to another. Replaces from_user with to_user across business models: @@ -159,25 +228,41 @@ def transfer_ownership(from_user: User, to_user: User | None) -> None: - OWNER/VIEWER membership rows (custom-through, UN-2202) — re-pointed, reconciling by role precedence when to_user already holds a row so the resource keeps an owner. + + ``membership_to`` splits the two halves. Audit fields may follow a user who + has left the org -- nulling them has no security value and breaks deletes + that dereference ``created_by`` -- while an OWNER row may not. """ - if not to_user: - return + if membership_to is _SAME_AS_TO_USER: + membership_to = to_user with transaction.atomic(): - for model in apps.get_models(): - if model._meta.app_label not in _BUSINESS_APP_LABELS: - continue - _transfer_model_ownership(model, from_user, to_user) + if to_user: + for model in apps.get_models(): + 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) + if membership_to: + _transfer_membership_rows(from_user, membership_to) def delete_api_user_for_key(platform_api_key: PlatformApiKey) -> None: - """Transfer ownership to key creator, then delete the service account.""" + """Transfer ownership to the key's creator, then delete the service account. + + Audit fields follow ``created_by`` even if they have left the org: deleting + the account is ``SET_NULL`` on those FKs, and a null ``created_by`` breaks + callers that dereference it. An OWNER row is a live-membership question, so + it goes only to a creator ``live_key_creator`` still admits. + """ api_user = platform_api_key.api_user if not api_user: return with transaction.atomic(): - transfer_ownership(from_user=api_user, to_user=platform_api_key.created_by) + transfer_ownership( + from_user=api_user, + to_user=platform_api_key.created_by, + membership_to=live_key_creator(platform_api_key), + ) api_user.delete() diff --git a/backend/platform_api/tests/test_owner_user_for.py b/backend/platform_api/tests/test_owner_user_for.py new file mode 100644 index 0000000000..3408de4f98 --- /dev/null +++ b/backend/platform_api/tests/test_owner_user_for.py @@ -0,0 +1,244 @@ +"""The ownership resolvers in isolation. + +End-to-end coverage of the create sites that call them lives in +`test_platform_key_resource_ownership.py`. +""" + +import secrets +import uuid +from types import SimpleNamespace + +from account_v2.enums import UserRole +from account_v2.models import Organization, User +from django.db import connection +from django.test.utils import CaptureQueriesContext +from permissions.roles import ResourceRole +from platform_api.models import ApiKeyPermission, PlatformApiKey +from platform_api.services import create_api_user_for_key, owner_user_for +from rest_framework.test import APIRequestFactory, APITestCase +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +ORG = "org-owner-test" + + +def _make_user() -> User: + email = f"user-{uuid.uuid4().hex[:8]}@example.com" + return User.objects.create_user( + username=email, email=email, password=secrets.token_urlsafe() + ) + + +class _KeyFixture: + """Org, a member who mints keys, and the minting path itself.""" + + def setUp(self) -> None: + self.org = Organization.objects.create( + name=ORG, display_name="Owner Test", organization_id=ORG + ) + + def _make_member(self) -> User: + """A key creator as production guarantees one. + + ``IsOrganizationAdmin`` resolves the caller's ``OrganizationMember`` + before allowing key creation, so a key's ``created_by`` is always a + member of that org at mint time. + """ + user = _make_user() + OrganizationMember.objects.create( + user=user, organization=self.org, role=UserRole.ADMIN.value + ) + return user + + def _make_key(self, created_by: User | None) -> PlatformApiKey: + key = PlatformApiKey.objects.create( + name=f"key-{uuid.uuid4().hex[:8]}", + description="test key", + organization=self.org, + permission=ApiKeyPermission.FULL_ACCESS, + created_by=created_by, + ) + # The minting path, for the `is_service_account` flag it sets. + create_api_user_for_key(key, self.org) + key.refresh_from_db() + return key + + +class OwnerUserForTest(_KeyFixture, APITestCase): + """The resolver in isolation.""" + + def test_a_normal_user_is_returned_unchanged(self) -> None: + user = _make_user() + self.assertEqual(owner_user_for(user), user) + + def test_a_normal_user_costs_no_query(self) -> None: + """The early return is the hot path — every create site calls this.""" + user = _make_user() + with CaptureQueriesContext(connection) as queries: + owner_user_for(user) + self.assertEqual(len(queries), 0) + + def test_a_service_account_resolves_to_the_keys_creator(self) -> None: + creator = self._make_member() + key = self._make_key(created_by=creator) + self.assertEqual(owner_user_for(key.api_user), creator) + + def test_a_deleted_creator_leaves_the_resource_ownerless(self) -> None: + """`created_by` is SET_NULL, so the key can outlive its creator.""" + key = self._make_key(created_by=self._make_member()) + PlatformApiKey.objects.filter(pk=key.pk).update(created_by=None) + self.assertEqual(owner_user_for(key.api_user), key.api_user) + + def test_a_service_account_with_no_key_stays_itself(self) -> None: + key = self._make_key(created_by=self._make_member()) + service_account = key.api_user + PlatformApiKey.objects.filter(pk=key.pk).delete() + self.assertEqual(owner_user_for(service_account), service_account) + + def test_a_creator_who_left_the_org_leaves_the_resource_ownerless(self) -> None: + """An ex-member must not be granted OWNER; it would survive re-invite.""" + creator = self._make_member() + key = self._make_key(created_by=creator) + # _base_manager: the default one is org-scoped and UserContext is unset. + OrganizationMember._base_manager.filter( + user=creator, organization=self.org + ).delete() + self.assertEqual(owner_user_for(key.api_user), key.api_user) + + def test_a_creator_in_a_different_org_is_not_granted(self) -> None: + """Membership is checked against the key's org, not any org.""" + creator = _make_user() + other_org = Organization.objects.create( + name="other", display_name="Other", organization_id="org-other" + ) + OrganizationMember.objects.create( + user=creator, organization=other_org, role=UserRole.ADMIN.value + ) + key = self._make_key(created_by=creator) + self.assertEqual(owner_user_for(key.api_user), key.api_user) + + +class KeyDeletionSuccessorTest(_KeyFixture, APITestCase): + """Deleting a key must not hand its rows to a departed creator.""" + + def _owner_row_users(self, resource): + return {m.user_id for m in resource.memberships.filter(role=ResourceRole.OWNER)} + + def _key_owned_workflow(self, creator): + from workflow_manager.workflow_v2.models.workflow import Workflow + + key = self._make_key(created_by=creator) + workflow = Workflow.objects.create( + workflow_name=f"wf-{uuid.uuid4().hex[:8]}", organization=self.org + ) + workflow.memberships.create( + user=key.api_user, role=ResourceRole.OWNER, organization=self.org + ) + return key, workflow + + def test_a_live_creator_inherits_the_rows(self) -> None: + creator = self._make_member() + key, workflow = self._key_owned_workflow(creator) + key.delete() + self.assertEqual(self._owner_row_users(workflow), {creator.id}) + + def test_a_departed_creator_inherits_nothing(self) -> None: + creator = self._make_member() + key, workflow = self._key_owned_workflow(creator) + OrganizationMember._base_manager.filter( + user=creator, organization=self.org + ).delete() + key.delete() + self.assertNotIn(creator.id, self._owner_row_users(workflow)) + + def test_a_departed_creator_still_keeps_the_audit_trail(self) -> None: + """Deleting the account is SET_NULL on created_by; a null breaks deletes.""" + creator = self._make_member() + key, workflow = self._key_owned_workflow(creator) + workflow.created_by = key.api_user + workflow.save(update_fields=["created_by"]) + OrganizationMember._base_manager.filter( + user=creator, organization=self.org + ).delete() + + key.delete() + + workflow.refresh_from_db() + self.assertEqual(workflow.created_by_id, creator.id) + + +class ServiceAccountStaysAuthorizedTest(_KeyFixture, APITestCase): + """Ownership no longer names the service account, so the gates that read + ownership need their own bypass -- otherwise a key cannot use what it made. + """ + + def test_adapter_access_admits_a_service_account(self) -> None: + from adapter_processor_v2.models import AdapterInstance + from tool_instance_v2.tool_instance_helper import ToolInstanceHelper + + creator = self._make_member() + key = self._make_key(created_by=creator) + adapter = AdapterInstance.objects.create( + adapter_name=f"a-{uuid.uuid4().hex[:8]}", + adapter_id=f"llm|{uuid.uuid4()}", + adapter_type="LLM", + adapter_metadata={}, + organization=self.org, + created_by=creator, + ) + adapter.grant_owner(creator) + # AdapterInstance.objects is org-scoped; without this the gate sees an + # empty queryset and the assertion proves nothing. + UserContext.set_organization_identifier(ORG) + self.addCleanup(UserContext.set_organization_identifier, None) + + # Raises PermissionDenied without the bypass. + ToolInstanceHelper.validate_adapter_access( + user=key.api_user, adapter_ids={str(adapter.id)} + ) + + def test_adapter_delete_admits_a_service_account(self) -> None: + """A key must be able to delete the adapter it created.""" + from adapter_processor_v2.models import AdapterInstance + from permissions.permission import IsFrictionLessAdapterDelete + + creator = self._make_member() + key = self._make_key(created_by=creator) + adapter = AdapterInstance.objects.create( + adapter_name=f"a-{uuid.uuid4().hex[:8]}", + adapter_id=f"llm|{uuid.uuid4()}", + adapter_type="LLM", + adapter_metadata={}, + organization=self.org, + created_by=creator, + ) + adapter.grant_owner(creator) + + request = APIRequestFactory().delete("/") + request.user = key.api_user + + self.assertTrue( + IsFrictionLessAdapterDelete().has_object_permission( + request, view=None, obj=adapter + ) + ) + + def test_workflow_permission_admits_a_service_account(self) -> None: + from workflow_manager.workflow_v2.models.workflow import Workflow + from workflow_manager.workflow_v2.permissions import IsWorkflowOwnerOrShared + + creator = self._make_member() + key = self._make_key(created_by=creator) + workflow = Workflow.objects.create( + workflow_name=f"wf-{uuid.uuid4().hex[:8]}", organization=self.org + ) + workflow.grant_owner(creator) + + request = APIRequestFactory().get("/") + request.user = key.api_user + view = SimpleNamespace(kwargs={"workflow_id": str(workflow.id)}) + # The permission resolves the workflow through the org-scoped manager. + UserContext.set_organization_identifier(ORG) + self.addCleanup(UserContext.set_organization_identifier, None) + + self.assertTrue(IsWorkflowOwnerOrShared().has_permission(request, view)) diff --git a/backend/platform_api/tests/test_platform_key_resource_ownership.py b/backend/platform_api/tests/test_platform_key_resource_ownership.py new file mode 100644 index 0000000000..1b03dac208 --- /dev/null +++ b/backend/platform_api/tests/test_platform_key_resource_ownership.py @@ -0,0 +1,254 @@ +"""A resource created through a platform key is owned by the key's creator. + +One case per resource that grants an OWNER row on create, kept together since +they all exercise one resolver. Each drives the real URLconf and middleware +with a key minted here; side effects unrelated to the grant are patched out. +""" + +import secrets +import uuid +from unittest.mock import patch + +from account_v2.enums import UserRole +from account_v2.models import Organization, User +from django.conf import settings +from django.test import override_settings +from permissions.roles import ResourceRole +from platform_api.models import ApiKeyPermission, PlatformApiKey +from platform_api.services import create_api_user_for_key +from rest_framework.test import APITestCase +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext +from workflow_manager.workflow_v2.models.workflow import Workflow + +ORG = "org-ownership" + +# Trimmed from the production chain, preserving its relative order. Pinning it +# keeps the suite behaving the same under the OSS and cloud test settings. +_MIDDLEWARE = [ + "middleware.request_id.CustomRequestIDMiddleware", + settings.TENANT_MIDDLEWARE, + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + settings.CUSTOM_AUTH_MIDDLEWARE, +] + + +@override_settings(MIDDLEWARE=_MIDDLEWARE) +class PlatformKeyResourceOwnershipTest(APITestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name=ORG, display_name="Ownership", organization_id=ORG + ) + email = f"creator-{uuid.uuid4().hex[:8]}@example.com" + self.creator = User.objects.create_user( + username=email, email=email, password=secrets.token_urlsafe() + ) + # ``IsOrganizationAdmin`` resolves the caller's membership before + # allowing key creation, so a key's creator is always a member of its + # org -- and ``owner_user_for`` re-checks that before granting. + OrganizationMember.objects.create( + user=self.creator, organization=self.org, role=UserRole.ADMIN.value + ) + self.key = PlatformApiKey.objects.create( + name=f"key-{uuid.uuid4().hex[:8]}", + description="test key", + organization=self.org, + permission=ApiKeyPermission.FULL_ACCESS, + created_by=self.creator, + ) + # The minting path, for the `is_service_account` flag it sets. + create_api_user_for_key(self.key, self.org) + + def tearDown(self) -> None: + # Left set, this thread-local scopes the managers in whatever runs next. + UserContext.set_organization_identifier(None) + + # -- helpers --------------------------------------------------------- + + def _post(self, path: str, payload: dict): + return self.client.post( + f"/{settings.PATH_PREFIX}/unstract/{ORG}/{path}", + payload, + format="json", + HTTP_AUTHORIZATION=f"Bearer {self.key.key}", + ) + + def _assert_owned_by_creator(self, instance) -> None: + """The OWNER row names the human who made the key, not the machine.""" + membership = instance.memberships.get(role=ResourceRole.OWNER) + self.assertEqual(membership.user, self.creator) + self.assertFalse( + membership.user.is_service_account, + "the OWNER row went to the key's service account", + ) + + def _make_workflow(self, *, api_endpoints: bool = False) -> Workflow: + UserContext.set_organization_identifier(ORG) + workflow = Workflow.objects.create( + workflow_name=f"wf-{uuid.uuid4().hex[:8]}", + organization=self.org, + created_by=self.creator, + ) + if api_endpoints: + # An API deployment is refused unless both endpoints exist and + # carry a connection type; API ones need no connector instance. + from workflow_manager.endpoint_v2.models import WorkflowEndpoint + + for endpoint_type in ( + WorkflowEndpoint.EndpointType.SOURCE, + WorkflowEndpoint.EndpointType.DESTINATION, + ): + WorkflowEndpoint.objects.update_or_create( + workflow=workflow, + endpoint_type=endpoint_type, + defaults={ + "connection_type": WorkflowEndpoint.ConnectionType.API + }, + ) + return workflow + + def _fetch(self, model, pk): + # Managers are org-scoped off a thread-local the request cleared. + UserContext.set_organization_identifier(ORG) + return model.objects.get(pk=pk) + + # -- resources ------------------------------------------------------- + + def test_workflow(self) -> None: + response = self._post( + "workflow/", {"workflow_name": f"wf-{uuid.uuid4().hex[:8]}"} + ) + self.assertEqual(response.status_code, 201, response.content) + self._assert_owned_by_creator(self._fetch(Workflow, response.json()["id"])) + + def test_prompt_studio_project(self) -> None: + from prompt_studio.prompt_studio_core_v2.models import CustomTool + + with patch( + "prompt_studio.prompt_studio_core_v2.views.PromptStudioHelper." + "create_default_profile_manager" + ): + response = self._post( + "prompt-studio/", + { + "tool_name": f"ps-{uuid.uuid4().hex[:8]}", + "description": "owned by the key's creator", + "author": "tester", + }, + ) + self.assertEqual(response.status_code, 201, response.content) + self._assert_owned_by_creator( + self._fetch(CustomTool, response.json()["tool_id"]) + ) + + def test_etl_pipeline(self) -> None: + from pipeline_v2.models import Pipeline + + workflow = self._make_workflow() + with patch("pipeline_v2.views.KeyHelper.create_api_key"): + response = self._post( + "pipeline/", + { + "pipeline_name": f"etl-{uuid.uuid4().hex[:8]}", + "workflow": str(workflow.id), + "pipeline_type": "ETL", + }, + ) + self.assertEqual(response.status_code, 201, response.content) + self._assert_owned_by_creator(self._fetch(Pipeline, response.json()["id"])) + + def test_api_deployment(self) -> None: + from api_v2.models import APIDeployment + + workflow = self._make_workflow(api_endpoints=True) + with ( + patch("api_v2.api_deployment_views.DeploymentHelper.create_api_key"), + patch("api_v2.api_deployment_views.notify_hubspot_event"), + ): + response = self._post( + "api/deployment/", + { + "display_name": f"api-{uuid.uuid4().hex[:8]}", + "api_name": f"api-{uuid.uuid4().hex[:8]}", + "description": "owned by the key's creator", + "workflow": str(workflow.id), + }, + ) + self.assertEqual(response.status_code, 201, response.content) + self._assert_owned_by_creator( + self._fetch(APIDeployment, response.json()["id"]) + ) + + def test_connector(self) -> None: + from connector_v2.models import ConnectorInstance + + workflow = self._make_workflow() + response = self._post( + "connector/", + { + "connector_name": f"conn-{uuid.uuid4().hex[:8]}", + # Must resolve in the connector registry, which is keyed by + # this exact string -- see ConnectorProcessor. + "connector_id": "minio|c799f6e3-2b57-434e-aaac-b5daa415da19", + "workflow": str(workflow.id), + "connector_mode": "FILESYSTEM", + "connector_metadata": { + "key": "test", + "secret": "test", + "endpoint_url": "http://localhost:9000", + "bucket": "test", + }, + }, + ) + self.assertEqual(response.status_code, 201, response.content) + self._assert_owned_by_creator( + self._fetch(ConnectorInstance, response.json()["id"]) + ) + + def test_prompt_studio_project_import(self) -> None: + """The import path grants its own OWNER row, separately from create. + + ``create_tool_from_import_data`` is reached by ``project-transfer/`` + and by ``sync-prompts/`` with ``create_copy``, and takes ``request.user`` + directly. Called here rather than over HTTP because the endpoint's other + work -- profile managers, prompt import, adapter validation -- is not + what is under test, and stubbing it would pin the stubs. + """ + from prompt_studio.prompt_studio_core_v2.models import CustomTool + from prompt_studio.prompt_studio_core_v2.prompt_studio_helper import ( + PromptStudioHelper, + ) + + tool = PromptStudioHelper.create_tool_from_import_data( + { + "tool_metadata": { + "tool_name": f"imported-{uuid.uuid4().hex[:8]}", + "author": "test", + "description": "imported through a platform key", + }, + "tool_settings": {}, + }, + f"imported-{uuid.uuid4().hex[:8]}", + self.org, + self.key.api_user, + ) + self._assert_owned_by_creator(self._fetch(CustomTool, tool.pk)) + + def test_adapter(self) -> None: + from adapter_processor_v2.models import AdapterInstance + + response = self._post( + "adapter/", + { + "adapter_name": f"adapter-{uuid.uuid4().hex[:8]}", + "adapter_id": "openai|502ecf49-e47c-445c-9907-6d4b90c5cd17", + "adapter_type": "LLM", + "adapter_metadata": {"adapter_name": "test", "api_key": "sk-test"}, + }, + ) + self.assertEqual(response.status_code, 201, response.content) + self._assert_owned_by_creator( + self._fetch(AdapterInstance, response.json()["id"]) + ) 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 774d6e19e3..a1d1cfb543 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 @@ -17,7 +17,6 @@ _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 @@ -2923,7 +2922,7 @@ def create_tool_from_import_data( # 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}) + tool.grant_owner(user) return tool diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 68c17adcdf..86a739efc8 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -19,7 +19,6 @@ 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 @@ -211,9 +210,7 @@ def create(self, request: HttpRequest) -> Response: ) # ``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} - ) + serializer.instance.grant_owner(request.user) PromptStudioHelper.create_default_profile_manager( request.user, serializer.data["tool_id"] ) diff --git a/backend/tenant_account_v2/migrations/0006_backfill_platform_key_ownership.py b/backend/tenant_account_v2/migrations/0006_backfill_platform_key_ownership.py new file mode 100644 index 0000000000..11cfa3b74d --- /dev/null +++ b/backend/tenant_account_v2/migrations/0006_backfill_platform_key_ownership.py @@ -0,0 +1,43 @@ +"""Re-point service-account OWNER rows to the key's live creator. + +Rows written before ``owner_user_for`` existed name the service account, which +every owner surface filters out. Skips a creator who has left the org, matching +the resolver. Idempotent. + +The repair itself lives in ``_membership_backfill.repair_platform_key_ownership`` +so a cloud-only app -- which this migration can't declare a dependency on -- +can import and re-run it after its own absorb-shared-users migration. +""" + +from django.db import migrations + +from tenant_account_v2.migrations._membership_backfill import ( + repair_platform_key_ownership, +) + + +def _forward(apps, schema_editor): + repair_platform_key_ownership(apps) + + +class Migration(migrations.Migration): + dependencies = [ + ("tenant_account_v2", "0005_resource_membership"), + ("platform_api", "0004_alter_platformapikey_organization"), + # UN-2202's backfills write an OWNER row from ``created_by``, which is + # the service account on a key-created resource. They must land before + # this one or their rows are written after the repair and stay + # ownerless -- ordering that was otherwise alphabetical accident, and + # already wrong for workflow_v2. + ("adapter_processor_v2", "0005_absorb_shared_users"), + ("api_v2", "0005_absorb_shared_users"), + ("connector_v2", "0007_absorb_shared_users"), + ("pipeline_v2", "0005_absorb_shared_users"), + ("prompt_studio_core_v2", "0009_absorb_shared_users"), + ("workflow_v2", "0022_absorb_shared_users"), + ] + + # Irreversible in substance: which rows were the service account's is not + # recoverable afterwards. Reversing is a no-op so the migration can still + # be unapplied without blocking a rollback. + operations = [migrations.RunPython(_forward, migrations.RunPython.noop)] diff --git a/backend/tenant_account_v2/migrations/_membership_backfill.py b/backend/tenant_account_v2/migrations/_membership_backfill.py index 00235b60f6..b9368db908 100644 --- a/backend/tenant_account_v2/migrations/_membership_backfill.py +++ b/backend/tenant_account_v2/migrations/_membership_backfill.py @@ -1,9 +1,9 @@ -"""Shared backfill for the UN-2202 single-table membership migration. +"""Shared backfills 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. +the per-app membership migrations -- OSS and cloud alike -- 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 @@ -12,6 +12,8 @@ import logging +from django.utils import timezone + logger = logging.getLogger(__name__) OWNER = "owner" @@ -68,3 +70,59 @@ def backfill_memberships(apps, app_label: str, model_name: str) -> None: skipped, skipped_org, ) + + +def repair_platform_key_ownership(apps) -> None: + """Re-point service-account OWNER rows to the key's live creator. + + Rows written before ``owner_user_for`` existed name the service account, + which every owner surface filters out. Skips a creator who has left the + org, matching the resolver. Safe to re-run: a resource type whose OWNER + rows are written by a migration that lands after this one (cloud-only + apps this module's app can't declare a dependency on) needs this called + again from a migration that depends on both. + """ + resource_membership_model = apps.get_model( + "tenant_account_v2", "ResourceMembership" + ) # NOSONAR + organization_member_model = apps.get_model( + "tenant_account_v2", "OrganizationMember" + ) # NOSONAR + platform_api_key_model = apps.get_model("platform_api", "PlatformApiKey") # NOSONAR + + # Keyed off key rows so only accounts actually backing a key move. + successor: dict[int, int] = {} + for key in platform_api_key_model.objects.exclude(api_user_id=None).exclude( + created_by_id=None + ): + if organization_member_model.objects.filter( + user_id=key.created_by_id, organization_id=key.organization_id + ).exists(): + successor[key.api_user_id] = key.created_by_id + + if not successor: + return + + rows = resource_membership_model.objects.filter( + role=OWNER, user_id__in=successor.keys() + ) + for row in rows.iterator(): + new_user_id = successor[row.user_id] + clash = resource_membership_model.objects.filter( + user_id=new_user_id, + content_type_id=row.content_type_id, + object_id=row.object_id, + ).first() + if clash is None: + row.user_id = new_user_id + # Historical models skip BaseModel.save's modified_at injection. + row.modified_at = timezone.now() + row.save(update_fields=["user", "modified_at"]) + continue + # Creator already holds a row here: keep the stronger role, drop the + # service account's, so the uniqueness constraint holds. + if clash.role != OWNER: + clash.role = OWNER + clash.modified_at = timezone.now() + clash.save(update_fields=["role", "modified_at"]) + row.delete() diff --git a/backend/tool_instance_v2/tool_instance_helper.py b/backend/tool_instance_v2/tool_instance_helper.py index 138e48b5b2..0669048a3b 100644 --- a/backend/tool_instance_v2/tool_instance_helper.py +++ b/backend/tool_instance_v2/tool_instance_helper.py @@ -523,6 +523,11 @@ def validate_adapter_access( ) -> None: adapter_instances = AdapterInstance.objects.filter(id__in=adapter_ids).all() is_admin = OrganizationMemberService.is_user_organization_admin(user) + # A platform key's service account is trusted across its own org, the + # same bypass every other access surface applies. Ownership no longer + # names it (UN-3853), so without this a key-provisioned workflow would + # fail its own execution. + is_service_account = getattr(user, "is_service_account", False) for adapter_instance in adapter_instances: if not adapter_instance.is_usable: @@ -536,6 +541,7 @@ def validate_adapter_access( if not ( is_admin + or is_service_account or adapter_instance.shared_to_org or _is_resource_owner(user, adapter_instance) or _is_resource_viewer(user, adapter_instance) diff --git a/backend/workflow_manager/workflow_v2/permissions.py b/backend/workflow_manager/workflow_v2/permissions.py index 01e0cb0ca0..d133ed4411 100644 --- a/backend/workflow_manager/workflow_v2/permissions.py +++ b/backend/workflow_manager/workflow_v2/permissions.py @@ -38,7 +38,9 @@ def has_permission(self, request, view): user = request.user has_access = ( - _is_resource_owner(user, workflow) + # Trusted across its own org; ownership no longer names it (UN-3853). + getattr(user, "is_service_account", False) + or _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/views.py b/backend/workflow_manager/workflow_v2/views.py index c999b7f9f5..e8f6d22723 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -10,7 +10,6 @@ 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 @@ -161,9 +160,7 @@ def perform_create(self, serializer: WorkflowSerializer) -> Workflow: ) # ``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} - ) + workflow.grant_owner(self.request.user) try: # Create empty WorkflowEndpoints for UI compatibility # ConnectorInstances will be created when users actually configure connectors diff --git a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx index b1e5bd0708..62867bacbf 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx @@ -85,6 +85,7 @@ function ApiDeployment() { fetchRef, handlePaginationChange, handleSearch, + handleListRefresh, } = usePaginatedList(); const { scrollRestoreId, activateScrollRestore, clearPendingScroll } = @@ -360,6 +361,7 @@ function ApiDeployment() { openCodeModal={setOpenCodeModal} setSelectedRow={setSelectedRow} workflowEndpointList={workflowEndpointList} + refreshList={handleListRefresh} /> )} { const workflowStore = useWorkflowStore(); const { updateWorkflow } = workflowStore; @@ -109,8 +110,9 @@ const CreateApiDeploymentModal = ({ onDeploymentCreated(); } } else { - // Add new deployment to list - setTableData((prev) => [res?.data, ...prev]); + // Refetch: the create response is a summary without the owner + // fields the list renders. + refreshList?.(); setSelectedRow(res?.data); openCodeModal(true); } @@ -294,6 +296,7 @@ CreateApiDeploymentModal.propTypes = { workflowEndpointList: PropTypes.object, setDeploymentName: PropTypes.func, onDeploymentCreated: PropTypes.func, + refreshList: PropTypes.func, }; export { CreateApiDeploymentModal }; diff --git a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx index 646d2fbced..4ea6ae0906 100644 --- a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx +++ b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx @@ -27,6 +27,7 @@ import { formattedDateTime, shortenApiEndpoint, } from "../../../helpers/GetStaticData"; +import { resolveOwnerDisplay } from "../owner-display"; /** * Reusable action box with Edit, Share, Delete icons and kebab menu @@ -139,11 +140,7 @@ CardActionBox.propTypes = { * @return {JSX.Element} Rendered owner field row */ function OwnerFieldRow({ item, sessionDetails, onManageCoOwners }) { - const isOwner = item?.is_owner ?? item.created_by === sessionDetails?.userId; - const email = item.created_by_email; - const name = isOwner ? "Me" : email?.split("@")[0] || "Unknown"; - const extra = - item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : ""; + const { email, name, extra } = resolveOwnerDisplay(item, sessionDetails); const ownerDisplay = `${name}${extra}`; const ownerContent = ( diff --git a/frontend/src/components/widgets/owner-display.js b/frontend/src/components/widgets/owner-display.js new file mode 100644 index 0000000000..a8cdb93f76 --- /dev/null +++ b/frontend/src/components/widgets/owner-display.js @@ -0,0 +1,34 @@ +// Service accounts live in this domain; label them rather than name them. +const PLATFORM_KEY_EMAIL_DOMAIN = "@platform.internal"; + +/** + * Resolve the "Owned By" label for a resource row. Shared by the table and + * card views so the two cannot drift. + * + * @param {object} item Resource row from a list endpoint. + * @param {object} sessionDetails Current session, for the "Me" comparison. + * @param {string} ownerEmailsProp Field holding the owner emails. + * @return {{email: string|undefined, name: string, extra: string}} + */ +function resolveOwnerDisplay(item, sessionDetails, ownerEmailsProp) { + // Earliest owner first; created_by_email covers rows with no OWNER row. + const ownerEmails = item?.[ownerEmailsProp ?? "owner_emails"]; + const rawEmail = + (Array.isArray(ownerEmails) ? ownerEmails[0] : undefined) ?? + item?.created_by_email; + const isPlatformKey = Boolean(rawEmail?.endsWith(PLATFORM_KEY_EMAIL_DOMAIN)); + const email = isPlatformKey ? undefined : rawEmail; + // Tracks the displayed owner, not the viewer's own membership. + const isMe = Boolean(email) && email === sessionDetails?.email; + let name = email?.split("@")[0] || "Unknown"; + if (isPlatformKey) { + name = "Platform key"; + } else if (isMe) { + name = "Me"; + } + const extra = + item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : ""; + return { email, name, extra }; +} + +export { resolveOwnerDisplay }; diff --git a/frontend/src/components/widgets/owner-display.test.js b/frontend/src/components/widgets/owner-display.test.js new file mode 100644 index 0000000000..41bb4b83c2 --- /dev/null +++ b/frontend/src/components/widgets/owner-display.test.js @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOwnerDisplay } from "./owner-display"; + +const SESSION = { email: "me@example.com" }; + +describe("resolveOwnerDisplay", () => { + it("prefers the first owner email over created_by_email", () => { + const { email, name } = resolveOwnerDisplay( + { + owner_emails: ["owner@example.com"], + created_by_email: "creator@example.com", + }, + SESSION, + ); + expect(email).toBe("owner@example.com"); + expect(name).toBe("owner"); + }); + + it("falls back to created_by_email when owner_emails is absent or empty", () => { + for (const item of [ + { created_by_email: "creator@example.com" }, + { owner_emails: [], created_by_email: "creator@example.com" }, + ]) { + expect(resolveOwnerDisplay(item, SESSION).email).toBe( + "creator@example.com", + ); + } + }); + + it("labels a service-account address 'Platform key' and shows no email", () => { + const { email, name } = resolveOwnerDisplay( + { created_by_email: "svc-key-1a2b3c4d@platform.internal" }, + SESSION, + ); + expect(name).toBe("Platform key"); + expect(email).toBeUndefined(); + }); + + it("reads 'Me' only when the DISPLAYED owner is the viewer", () => { + expect( + resolveOwnerDisplay({ owner_emails: ["me@example.com"] }, SESSION).name, + ).toBe("Me"); + // A co-owner viewing a resource someone else owns must not read "Me". + expect( + resolveOwnerDisplay({ owner_emails: ["other@example.com"] }, SESSION) + .name, + ).toBe("other"); + }); + + it("suffixes the extra co-owners, and nothing when there is one owner", () => { + const item = { owner_emails: ["a@example.com"] }; + expect( + resolveOwnerDisplay({ ...item, co_owners_count: 3 }, SESSION).extra, + ).toBe(" +2"); + expect( + resolveOwnerDisplay({ ...item, co_owners_count: 1 }, SESSION).extra, + ).toBe(""); + }); + + it("renders 'Unknown' when the row carries no owner field at all", () => { + expect(resolveOwnerDisplay({}, SESSION).name).toBe("Unknown"); + }); +}); diff --git a/frontend/src/components/widgets/resource-table/ResourceTable.jsx b/frontend/src/components/widgets/resource-table/ResourceTable.jsx index c2a99ecb35..d218b51076 100644 --- a/frontend/src/components/widgets/resource-table/ResourceTable.jsx +++ b/frontend/src/components/widgets/resource-table/ResourceTable.jsx @@ -22,6 +22,7 @@ import { Table } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; import { formattedDateTime, timeAgo } from "../../../helpers/GetStaticData"; +import { resolveOwnerDisplay } from "../owner-display"; import "./ResourceTable.css"; // Stable, distinct avatar swatch per owner (seeded on email/name) like the @@ -217,20 +218,11 @@ function ResourceTable({ }; const renderOwner = (item) => { - // owner_emails is earliest-first; [0] is the primary shown owner. - // Fall back to created_by_email so rows with no live OWNER membership - // (platform API-key sessions, pre-backfill rows) don't render "Unknown". - const ownerEmails = item?.[ownerEmailsProp]; - const email = - (Array.isArray(ownerEmails) ? ownerEmails[0] : undefined) ?? - item?.created_by_email; - // "Me" must track the DISPLAYED owner, not the viewer's own membership — - // else a co-owner sees "Me" over the primary owner's avatar/email. Match on - // the shown email so the creator viewing their own resource still reads "Me". - const isMe = Boolean(email) && email === sessionDetails?.email; - const name = isMe ? "Me" : email?.split("@")[0] || "Unknown"; - const extra = - item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : ""; + const { email, name, extra } = resolveOwnerDisplay( + item, + sessionDetails, + ownerEmailsProp, + ); const initials = (email || name).slice(0, 2).toUpperCase(); const swatch = colorForSeed(email || name); diff --git a/specs/docstudio-oss.json b/specs/docstudio-oss.json index 7bd536cda3..36a7ffe203 100644 --- a/specs/docstudio-oss.json +++ b/specs/docstudio-oss.json @@ -65,6 +65,14 @@ "readOnly": true, "type": "string" }, + "owner_emails": { + "description": "Email of each owner, earliest first. Empty if none is a person.", + "items": { + "type": "string" + }, + "readOnly": true, + "type": "array" + }, "run_count": { "readOnly": true, "type": "integer" @@ -91,6 +99,7 @@ "is_owner", "last_5_run_statuses", "last_run_time", + "owner_emails", "run_count", "workflow", "workflow_name"