Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -531,6 +532,7 @@ class Meta:
"last_run_time",
"is_owner",
"co_owners_count",
"owner_emails",
]

def get_created_by_email(self, obj):
Expand All @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions backend/pipeline_v2/serializers/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion backend/pipeline_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 36 additions & 1 deletion backend/platform_api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
)
Expand All @@ -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 [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
4 changes: 3 additions & 1 deletion backend/workflow_manager/workflow_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/widgets/owner-display.js
Original file line number Diff line number Diff line change
@@ -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 };
20 changes: 6 additions & 14 deletions frontend/src/components/widgets/resource-table/ResourceTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
Loading