diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index f2aef82b0c..2e6bd025fa 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -16,6 +16,7 @@ ) from permissions.resource_share_views import ResourceShareManagementMixin from permissions.roles import ResourceRole +from platform_api.services import owner_user_for from plugins import get_plugin from rest_framework import status from rest_framework.decorators import action @@ -272,7 +273,8 @@ 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). instance.memberships.get_or_create( - user_id=request.user.id, defaults={"role": ResourceRole.OWNER} + user_id=owner_user_for(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 8f5d0763a9..e34948b9e6 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -9,6 +9,7 @@ from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin from permissions.roles import ResourceRole +from platform_api.services import owner_user_for from plugins import get_plugin from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry from rest_framework import serializers, status, views, viewsets @@ -326,7 +327,8 @@ def create( # ``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} + user_id=owner_user_for(request.user).id, + defaults={"role": ResourceRole.OWNER}, ) api_key = DeploymentHelper.create_api_key(serializer=serializer, request=request) response_serializer = DeploymentResponseSerializer( diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index a8703f01d1..b39e68d45c 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -512,6 +512,7 @@ class APIDeploymentListSerializer(ModelSerializer): last_run_time = SerializerMethodField() is_owner = SerializerMethodField() co_owners_count = SerializerMethodField() + owner_emails = SerializerMethodField() class Meta: model = APIDeployment @@ -531,6 +532,7 @@ class Meta: "last_run_time", "is_owner", "co_owners_count", + "owner_emails", ] def get_created_by_email(self, obj): @@ -544,6 +546,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_run_count(self, instance) -> int: """Get total execution count for this API deployment.""" return WorkflowExecution.objects.filter(pipeline_id=instance.id).count() diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index fd75b749db..a764aa932c 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -12,6 +12,7 @@ from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin from permissions.roles import ResourceRole +from platform_api.services import owner_user_for from plugins import get_plugin from rest_framework import status, viewsets from rest_framework.decorators import action @@ -259,7 +260,8 @@ 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} + user_id=owner_user_for(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) 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..831727e684 100644 --- a/backend/pipeline_v2/views.py +++ b/backend/pipeline_v2/views.py @@ -13,6 +13,7 @@ from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin from permissions.roles import ResourceRole +from platform_api.services import owner_user_for from plugins import get_plugin from rest_framework import serializers, status, viewsets from rest_framework.decorators import action @@ -159,7 +160,8 @@ def create(self, request: Request) -> Response: # 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} + user_id=owner_user_for(request.user).id, + defaults={"role": ResourceRole.OWNER}, ) # Create API key using the created instance KeyHelper.create_api_key(pipeline_instance, request) diff --git a/backend/platform_api/services.py b/backend/platform_api/services.py index 4087741227..203f337329 100644 --- a/backend/platform_api/services.py +++ b/backend/platform_api/services.py @@ -15,6 +15,10 @@ from platform_api.models import PlatformApiKey +# 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 +49,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 +67,37 @@ def create_api_user_for_key( return user +def owner_user_for(user: User) -> User: + """Resolve the human who should own a resource created by ``user``. + + A platform key authenticates as a service account, and service accounts are + filtered out of every owner surface (``HasMembersMixin``), so a resource + granted to one has no human owner: it is invisible to its creator and only + an org admin can manage it. Attribute it to the key's creator instead — the + same successor :func:`delete_api_user_for_key` already hands ownership to. + + Returns ``user`` unchanged for a normal session, and for the residual case + where the key's creator has since been deleted (``created_by`` is + ``SET_NULL``) — such a resource stays deliberately ownerless and the UI + labels it "Platform key". + + Org membership of the creator is deliberately not re-checked: a key can + outlive its creator's membership, and granting to an ex-member matches what + :func:`delete_api_user_for_key` already does. The row is inert until they + rejoin, which beats leaving the resource with no owner at all. + """ + 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").first() + ) + return key.created_by if key and key.created_by else user + + def _get_user_fk_fields(model: type) -> list[str]: """Return names of all ForeignKey fields pointing to User.""" return [ 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 94f3850c62..2caeecec9d 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 @@ -18,6 +18,7 @@ has_group_access, ) from permissions.roles import ResourceRole +from platform_api.services import owner_user_for from plugins import get_plugin from rest_framework.exceptions import APIException from rest_framework.request import Request @@ -2919,7 +2920,9 @@ 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.memberships.get_or_create( + user=owner_user_for(user), defaults={"role": ResourceRole.OWNER} + ) 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 8990e870eb..ffbac48387 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -19,6 +19,7 @@ from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin from permissions.roles import ResourceRole +from platform_api.services import owner_user_for from plugins import get_plugin from rest_framework import status, viewsets from rest_framework.decorators import action @@ -209,7 +210,8 @@ 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} + user_id=owner_user_for(request.user).id, + defaults={"role": ResourceRole.OWNER}, ) PromptStudioHelper.create_default_profile_manager( request.user, serializer.data["tool_id"] diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index fefba8c21a..9305800627 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -13,6 +13,7 @@ from permissions.roles import ResourceRole from pipeline_v2.models import Pipeline from pipeline_v2.pipeline_processor import PipelineProcessor +from platform_api.services import owner_user_for from plugins import get_plugin from rest_framework import serializers, status, viewsets from rest_framework.decorators import action, api_view @@ -162,7 +163,8 @@ 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} + user_id=owner_user_for(self.request.user).id, + defaults={"role": ResourceRole.OWNER}, ) try: # Create empty WorkflowEndpoints for UI compatibility 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..0900cbc237 --- /dev/null +++ b/frontend/src/components/widgets/owner-display.js @@ -0,0 +1,44 @@ +// Service-account address minted by `create_api_user_for_key`. Its owner is a +// platform API key, not a person, so the field is labelled rather than named. +const PLATFORM_KEY_EMAIL_DOMAIN = "@platform.internal"; + +/** + * Resolve the "Owned By" label for a resource row. + * + * Shared by the list table and the card views so the two cannot drift — they + * previously disagreed on both the source field and the "Me" rule. + * + * @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) { + // owner_emails is earliest-first; [0] is the primary shown owner. Fall back + // to created_by_email so rows with no live OWNER membership (pre-backfill + // rows) don't render "Unknown". + const ownerEmails = item?.[ownerEmailsProp ?? "owner_emails"]; + const rawEmail = + (Array.isArray(ownerEmails) ? ownerEmails[0] : undefined) ?? + item?.created_by_email; + // Reached only when a platform key's creator has since been deleted, so no + // human can be named. Suppress the synthetic address rather than dress a + // machine identity up as a colleague. + const isPlatformKey = Boolean(rawEmail?.endsWith(PLATFORM_KEY_EMAIL_DOMAIN)); + const email = isPlatformKey ? undefined : rawEmail; + // "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; + 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/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);