Skip to content

UN-3853 [FIX] Attribute platform-key-created resources to the API key's creator - #2274

Merged
kirtimanmishrazipstack merged 16 commits into
mainfrom
UN-3853-co-ownership-resources-with-platform-key
Sep 17, 2026
Merged

kirtimanmishrazipstack merged 16 commits into
mainfrom
UN-3853-co-ownership-resources-with-platform-key

Conversation

@kirtimanmishrazipstack

@kirtimanmishrazipstack kirtimanmishrazipstack commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Anything created with a platform API key — Prompt Studio project, workflow, ETL pipeline, API deployment, connector or adapter — now belongs to the person who created that key, instead of belonging to nobody.
  • Resources created before this change are repaired automatically on deploy.
  • If that person has since left or been deleted, the "Owned By" column reads Platform key instead of showing an email address for someone who does not exist.

Why

  • When a script talks to Unstract with a platform API key, Unstract signs in as an invisible robot account that belongs to the key. Everything the script created was recorded as owned by that robot.
  • Unstract deliberately hides robots anywhere a person's name belongs. So these resources ended up with no human owner at all: they did not appear in anyone's list, the "Owned By" column had nothing real to show, and only an org admin could open, share or delete them.
  • Reported by Chandrasekharan M — a project showing UN / Unknown in Owned By.
  • Today the damage is limited, because only org admins can create platform keys and admins can reach everything anyway. But the moment that admin is demoted or leaves, the resource has nobody attached to it.

How

  • New helper owner_user_for(user) in backend/platform_api/services.py. If the acting user is a platform key's service account it returns the key's created_by; otherwise it returns the user unchanged, before any query.
  • This is not a new rule. delete_api_user_for_key already hands a key's resources to created_by when the key is deleted. This applies the same successor at creation time instead of only at deletion.
  • Exposed as HasMembersMixin.grant_owner(), so a create site is a single call and cannot forget the rule. Seven sites in this repo route through it.
  • A creator who has since left the organization is not granted ownership. Handing them a fresh OWNER row would survive a re-invite, which is exactly what cleanup_user_org_access purges rows to prevent.
  • created_by is untouched everywhere. It is an audit field, and "the robot did it" is the truthful answer.
  • The "Owned By" label rule lives in one place (owner-display.js) so the table and card views cannot drift apart.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why.

  • No. For a normal signed-in session the helper returns the user unchanged before touching the database, so every create done through the UI behaves exactly as before.
  • The only behaviour that changes is the platform-key path, which today produces an ownerless resource.
  • The robot account keeps full access to everything in its org — permission checks short-circuit on it before ownership is ever consulted.
  • Signup and onboarding are unaffected: they always run as a real person, so the helper returns early.

Database Migrations

  • Yes, one. tenant_account_v2/0006_backfill_platform_key_ownership re-points existing robot-owned rows to the key's creator, so resources made before this change get a real owner too.
  • It is safe to run twice, and it skips any creator who has left the organization.

Env Config

  • None.

Relevant Docs

  • None.

Dependencies Versions

  • None.

Notes on Testing

Two people in the same organization, both org admins:

  • user-1 — kirtiman.mishra+sept4_1@zipstack.com
  • org admin — kirtiman.mishra+sept4@zipstack.com

Each created their own platform API key, then used their own key to create resources:

  • user-1's key created un-3853-user-wf
  • the org admin's key created un-3853-api-key-1

What each of them sees in the Owned By column:

Resource Created using user-1 sees org admin sees
un-3853-user-wf user-1's key Me kirtiman.mishra+sept4_1
un-3853-api-key-1 org admin's key kirtiman.mishra+sept4 Me
un3853-live-owner-probe org admin's key, two owners kirtiman.mishra+sept4 +1 Me +1

The two views never disagree about who owns a row. They differ only in how "you" is written: each person reads Me on the rows they own, and the other person's email address on the rest. +1 means the resource has a second owner.

Before this change every one of these rows read Platform key, and neither person could open, share or delete them — only an org admin could.

The same was checked by creating each resource type through a platform API key: workflow, ETL pipeline, API deployment, Prompt Studio project, connector, adapter, and the agentic project on the cloud side. Every one is owned by the key's creator.

Automated coverage: 16 backend tests and 6 frontend tests. The backend ones drive the real URL and login path with a key created inside the test, so the robot-account swap is genuinely exercised. Each was checked by reverting the fix and confirming the test fails.

Screenshots

What user-1 sees — their own workflow reads "Me", the org admin's reads the admin's address:
1

What the org admin sees — the mirror image:
2

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

@greptile-apps

greptile-apps Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

The PR attributes resources created through platform API keys to each key’s active human creator while preserving the service account as the audit creator.

  • Centralizes ownership resolution in owner_user_for() and grant_owner().
  • Backfills existing resource membership rows while excluding creators who have left the organization.
  • Adds consistent owner display behavior across table and card views.
  • Preserves platform-key access through organization-scoped service-account authorization paths.
  • Extends API deployment responses and the published schema with owner email data.

Reviews (13) · Last reviewed commit: "Merge branch 'main' into UN-3853-co-owne..."

@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

Merge this before the cloud PR

Zipstack/unstract-cloud#1753 imports owner_user_for from platform_api.services, and cloud CI pins the OSS tree to refs/heads/main with no override — so its test (integration) and e2e stay red until this merges. Once this lands, a re-run there goes green on its own.

…reator

A platform API key authenticates as a service account, and every resource
create path granted the OWNER membership row to that machine identity. Service
accounts are filtered out of every owner surface (HasMembersMixin), so such a
resource ended up with no human owner: invisible to its creator in list views,
manageable only through the org-admin fallback, and rendered in "Owned By" as a
synthetic @platform.internal address dressed up as a colleague.

Record the key's creator as owner instead — the same successor
delete_api_user_for_key already hands ownership to when a key is deleted, now
applied at creation rather than only at deletion. The service account loses
nothing: permission classes and for_user() short-circuit on is_service_account.

Where no human can be named (the key's creator has since been deleted), the
resource stays deliberately ownerless and the table labels it "Platform key"
rather than naming a machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHnDZZWGhsevUdwgMyR2ai
…s too

The ticket asks for Owned By to be correct on every resource type. The API
deployment and ETL pipeline card views were still wrong: OwnerFieldRow read
created_by_email only, and their serializers never exposed owner_emails — so
those cards named the audit creator, which on a platform-key create is the
service account. The backend fix alone could not reach them.

Expose owner_emails on both serializers (their querysets already prefetch
memberships__user, so it costs no extra query), and move the owner-label rule
into one resolveOwnerDisplay helper shared by the table and the cards. The two
had already drifted on both the source field and the "Me" rule — the card said
"Me" to any owner, which is the co-owner bug the table's comment warns about.
Cards now match the table: "Me" tracks the displayed owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHnDZZWGhsevUdwgMyR2ai
@kirtimanmishrazipstack
kirtimanmishrazipstack force-pushed the UN-3853-co-ownership-resources-with-platform-key branch from 90013a8 to b818d85 Compare September 3, 2026 06:10
…platform-key

Both conflicts were adjacency-only in backend/api_v2 — each side kept:
- api_deployment_views.py: main's PlatformKeyAutoSchema import landed on the
  line owner_user_for occupies.
- serializers.py: main's get_run_count comment abuts get_owner_emails.

Regenerated specs/docstudio-oss.json: #2278 publishes the deployment listing,
so owner_emails now belongs in APIDeploymentSummary and the committed spec
must byte-match a fresh render (test_docstudio_spec).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg
`owner_user_for` had no coverage. Adds the resolver's own branches (normal
user early-returns with no query, service account resolves to the key's
creator, a deleted creator or a missing key leaves the resource ownerless)
and one case per OSS resource that grants an OWNER row on create: workflow,
prompt studio, ETL pipeline, API deployment, connector, adapter.

The resource cases drive the real URLconf and middleware chain with a key
minted in the test, so the service-account swap that caused the bug is
exercised rather than simulated. Verified by mutation: reverting each call
site to the raw request user fails the matching case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg
F1: owner_user_for granted an OWNER membership row to the key's creator
without checking they are still a member of the key's organization.
_is_resource_owner grants on any surviving OWNER row with no live-membership
check, which is why cleanup_user_org_access purges those rows on departure;
minting a new one afterwards reopened that rejoin backdoor for the key's
lifetime. AddOwnerSerializer already refuses both non-members and service
accounts, so these sites were the only OWNER writes without the gate.

The check reads _base_manager: the default manager is org-scoped by
UserContext, which is unset outside a request, so the plain manager would
return empty and silently strip every resource of its owner.

F6: the fallback arms now log, naming the key id (not the key itself).
F7, F8: the docstring claimed service accounts are filtered from "every"
owner surface and that the residual resource "stays deliberately ownerless".
Neither held -- owner_memberships() and is_owner() apply no such filter, and
a real OWNER row is written that the last-owner guard counts as live.

Tests: the fixtures created a key creator with no OrganizationMember row, a
user who could never have created a key through IsOrganizationAdmin. They
now build the member production guarantees, and two cases pin the new guard
(creator left the org; creator belongs to a different org). Both fail when
the guard is neutered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC
…er the gaps

F5: the create response is a seven-field summary carrying none of the owner
fields the list renders, and the modal spliced it straight into the table, so
a just-created API deployment showed no owner until the next fetch. It now
refetches; the response is still handed to the code modal, which is the only
thing that needs the API key. The edit path already merged over the existing
row, so it kept its owner fields and is unchanged.

F3: reverting the seventh grant site (create_tool_from_import_data, reached by
project-transfer/ and sync-prompts/ with create_copy) left the whole backend
suite green -- six cases covered seven sites. A case now pins it, and fails
when that site is reverted.

F9: the comment called the "Platform key" branch reachable only via a deleted
creator. With no backfill it is also every resource created through a key
before this change; the accurate statement already sits on the fallback above.

F11: resolveOwnerDisplay decides the Owned By cell on every list and card and
had no test -- the only suite rendering it asserts nothing about that cell.
Six cases now cover its four branches; they fail when the platform-key
suppression or the "Me" rule is broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC
…he field

F10: resources created through a platform key before this change hold an OWNER
row naming the service account, which every owner surface filters out, so they
show no owner and only an org admin can manage them. Nothing moved them -- the
only re-pointer runs from delete_api_user_for_key. A data migration now hands
each to the same successor owner_user_for picks, under the same membership
rule: a creator who has left the org is skipped rather than handed a fresh
OWNER row, which would reopen the rejoin backdoor. Where the creator already
holds a row on that resource the stronger role is kept and the service
account's is dropped, so the unique triple is never violated.

Verified on a disposable database seeded with all three cases -- live creator,
departed creator, and a creator already holding a VIEWER row: forward applies,
reverse unapplies, and a re-apply is a no-op.

F4: APIDeploymentSummary subclasses the list serializer and declares no fields
of its own, so owner_emails reached the published platform-key contract with no
description. Stripping it there would only desynchronise the spec from the
response, and the UI reads the same endpoint, so the field stays and the
docstring now documents what a caller receives. Spec regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC
F12: resolving the acting user was a rule every create site had to remember,
applied at seven sites here and four in cloud. That is how the agentic
sample-project site came to be missed. HasMembersMixin is already on every one
of these models, so grant_owner() lives there and each site calls it -- the
rule now has one enforcement point, and all seven ownership cases fail when it
is broken, instead of one case per site.

Also drops seven imports of platform_api from the business apps, for an
operation permissions/ owns; the resolver import is now lazy and in one place.

Out of scope, checked: sharing_helpers grants VIEWER rather than create-time
OWNER, and platform_admin's two onboarding grants take the human from signup
rather than request.user, so no bearer session reaches them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC
…ill paths

Found by adversarial verification of the previous commits.

F21: closing the create path left delete_api_user_for_key still handing every
OWNER row to the key's creator with no membership check, so deleting a key
re-granted a departed creator the rows the create path had just refused --
the same rejoin backdoor, one step later. The membership question is now
live_key_creator(), asked by both paths instead of stated twice, and
transfer_ownership already short-circuits on None. Two cases pin it.

F22: migration 0006 declared no edge to UN-2202's *_absorb_shared_users
backfills, which write an OWNER row from created_by -- the service account on
a key-created resource. Six of the eight sorted before 0006 by alphabetical
accident and workflow_v2.0022 sorted after it, so workflows would have been
backfilled ownerless and left that way. The edges are now declared;
workflow_v2.0022 moves from plan index 289 to 183, immediately before 0006.

F23: the create-modal refresh called the fetch with its own defaults, so it
dropped any active search and reset to page 1 -- and a never-run deployment
sorts into the tail, so it was usually not on the page it landed on. It now
uses handleListRefresh, which the hook documents for exactly this and which
the sibling delete and co-owner callbacks already use.

Also corrects two comments this branch introduced: the owner-display pointer
named a fallback that states something else, and the lazy-import note named a
cycle that does not exist (the real constraint is import-time model loading).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

The red CI here is an infra issue, unrelated to this change

test (integration) and e2e both fail while pulling the MinIO images from Docker Hub, before any test runs:

pull access denied for minio/minio, repository does not exist or may require 'docker login'
No such image: minio/minio:latest

report is red only because those two are. Nothing in this branch is involved — the tiers that do exercise it pass.

… tree

Sonar flagged all three `apps.get_model` bindings in the backfill (python:S117).
`0005_add_reconciliation_task` already uses the `*_model` form, so this matches
the migration the repo most recently reviewed rather than the older PascalCase
one in `0004_pg_periodic_tasks`. The quoted model names are untouched -- those
are lookup keys, not identifiers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg
The comments had grown into mechanism retellings and change history -- why a
site was missed, what the two views used to disagree on, which surface filters
what. That rots as soon as the code moves and costs every later reader.

Keeps the contract (what a function returns, why a lazy import, why
_base_manager) and drops the narration. No code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

@Deepak-Kesavan Deepak-Kesavan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review

Automated review by Unstract PR review kit (Claude Code). Each finding below was reproduced against the code rather than inferred, so treat it as something to resolve before merge. If one is wrong, disagree on the thread and close it — that is the expected way to clear a finding. Anything tagged [unverified] was not reproduced and is flagged for your judgement instead.

4 inline comment(s) · 1 finding(s) not on changed lines (below).

Findings off the diff

  • backend/api_v2/api_deployment_views.py:406
    [minor] by_prompt_studio_tool serializes with APIDeploymentListSerializer on a queryset that has neither select_related("created_by") nor prefetch_related("memberships__user"), so the new owner_emails SerializerMethodField adds one membership query plus one user query per membership row, per deployment.

GET /api/v1/unstract//api/deployment/by_prompt_studio_tool/?tool_id= with N matching deployments. get_owner_emails (backend/api_v2/serializers.py:547-551) calls obj.owner_emails(), which iterates self.memberships.all() and dereferences m.user.email (backend/permissions/models.py:60-70). With no prefetch cache, memberships.all() issues a fresh SELECT per row and each m.user issues another, so the endpoint gains 1 + (#owners) queries per deployment on top of the pre-existing per-row cost. The two list viewsets are prefetched (api_deployment_views.py:300, pipeline_v2/views.py:86); this caller was missed.

Suggested fix: Add the same eager loading at backend/api_v2/api_deployment_views.py:406: APIDeployment.objects.for_user(request.user).select_related("created_by").prefetch_related("memberships__user").filter(workflow_id__in=workflow_ids).

🤖 Unstract PR review kit (Claude Code) · review-pr-bot:165a1da9d29f

review-pr-bot:review

Comment thread backend/permissions/models.py
Comment thread backend/platform_api/services.py Outdated
Comment thread backend/tenant_account_v2/migrations/0006_backfill_platform_key_ownership.py Outdated
… intact

Review findings from #2274.

Moving the OWNER row off the service account broke two gates that authorize by
OWNER membership and have no service-account bypass, so a workflow provisioned
through a platform key failed its own execution: `validate_adapter_access` and
`IsWorkflowOwnerOrShared.has_permission`. Both now short-circuit on a service
account, matching every other access surface.

On key deletion the audit half and the ownership half are now separate.
`created_by`/`modified_by` follow the key's creator even if they have left the
org -- deleting the account is SET_NULL on those FKs, and a null `created_by`
breaks `CustomTool.delete()`. The OWNER row still goes only to a live member.

Also: eager-load `by_prompt_studio_tool` so `owner_emails` does not fan out,
write `modified_at` in the backfill (historical models skip BaseModel.save),
and return to page 1 after creating a deployment, since never-run rows sort
last and the new one could land off-page.

Three regression tests, each verified by reverting its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@Deepak-Kesavan Thanks — all five findings reproduced against the code, all five fixed in a57f99b (OSS) and 402f4ea1 (cloud). Replies are on each thread; this one covers the off-diff finding.

by_prompt_studio_tool N+1 — confirmed, fixed. The two list viewsets do carry select_related("created_by").prefetch_related("memberships__user"); this caller had neither while serializing with the same serializer, so the new owner_emails field fanned out per deployment. Added the same eager loading.

Two notes on the process, since they affected the outcome:

One of my regression tests was initially vacuous. The test for the critical adapter-access finding passed with the fix reverted, because AdapterInstance.objects is org-scoped and UserContext was unset — the gate looped over an empty queryset. Caught it by mutation-testing each new test rather than trusting green. Every test added here was verified by reverting its own fix.

The cloud storage finding was invisible locally. That test passed on my machine because my dev .env supplies storage config the CI rig doesn't set — so it was green here and would have errored in CI, with the mutation check passing for the wrong reason. Exactly as you called it.

Full suite after the fixes: 382 passed, spec check clean, hooks clean on both repos.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

Rewiring the create modal to the page-1 refresh left `handleListRefresh`
destructured but unread here. The hook still exports it and Workflows.jsx
still uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg
…apps

tenant_account_v2's backfill migration can't depend on cloud-only apps'
absorb_shared_users migrations, so their service-account OWNER rows could
land after the repair and never get revisited. Extract the repair into
_membership_backfill so a cloud migration can import and re-run it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@Deepak-Kesavan Deepak-Kesavan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review (re-review)

Automated review by Unstract PR review kit (Claude Code). Each finding below was reproduced against the code rather than inferred, so treat it as something to resolve before merge. If one is wrong, disagree on the thread and close it — that is the expected way to clear a finding. Anything tagged [unverified] was not reproduced and is flagged for your judgement instead.

1 inline comment(s) · 1 finding(s) not on changed lines (below).

Findings off the diff

  • backend/permissions/permission.py:285
    [major] The fix patched the two authz gates that were reported, but IsFrictionLessAdapterDelete is a third ownership gate with no is_service_account bypass — so a full_access platform key can no longer delete an adapter it created through that same key.
  1. Admin mints a full_access platform key.
  2. POST /api/v1/unstract/<org>/adapter/ with Authorization: Bearer <key> creates an LLM adapter — the exact flow this PR's own test_platform_key_resource_ownership.test_adapter asserts. grant_owner(request.user) resolves the service account to the human creator, so the only OWNER row names the human.
  3. The same key issues DELETE /api/v1/unstract/<org>/adapter/<id>/. AdapterInstance.objects.for_user(SA) returns .all(), so the object resolves; then IsFrictionLessAdapterDelete.has_object_permission runs (adapter_processor_v2/views.py:170-171 routes destroy to it):
    • obj.is_friction_less -> False
    • _is_resource_owner(SA, adapter) -> False (post-PR the row is the human's; pre-PR it was the SA's and this returned True)
    • _is_organization_admin(SA) -> False by design (organization_member_service.py:39 short-circuits service accounts)
      => 403, where main returned 204.

permissions/permission.py:274-287 is the only _is_resource_owner call site in the tree without a service-account short-circuit. Every sibling in the same file has one (:114, :131, :163, :194, :229, :243, :268), including IsFrictionLessAdapter immediately above it, and prompt_studio/permission.py:24, :51 have one too. The asymmetry is right next to the fix.

It ships green: the new ServiceAccountStaysAuthorizedTest covers exactly the two sites that were reported, and test_platform_key_resource_ownership.test_adapter asserts the create but never the subsequent delete.

This also still falsifies the PR body's claim that "the robot account keeps full access to everything in its org".

Suggested fix: Add the bypass the same way every sibling in the file does, after the frictionless carve-out:

def has_object_permission(self, request, view, obj):
    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)

Worth adding a DELETE .../adapter/<id>/ case to ServiceAccountStaysAuthorizedTest — the two cases it has would not have caught this one.

🤖 Unstract PR review kit (Claude Code) · review-pr-bot:d82dbd8bdf70

review-pr-bot:review

Comment thread frontend/src/components/deployments/api-deployment/ApiDeployment.jsx Outdated
Re-review findings from #2274.

`IsFrictionLessAdapterDelete` is a third gate that authorizes by OWNER
membership with no service-account bypass, so a full_access key could no longer
delete an adapter it had just created. Every sibling in the file has the
bypass, including `IsFrictionLessAdapter` directly above it.

Swept the rest rather than wait for a fourth: all 13 `_is_resource_owner` call
sites across both repos now either carry the bypass or are unreachable by a
service account -- `prompt_studio_helper._adapter_accessible_by` is guarded by
an `is_service_account` early return at its only caller.

The page-1 refresh is reverted. The ordering is `last_run_time DESC NULLS
LAST`, so a never-run deployment sorts to the END -- page 1 is the wrong
target, and jumping there also threw the user off the page the row was
actually on. `handleListRefresh` at least keeps them there. Making the new row
visible needs an ordering change, which is a product decision, not this PR.

Regression test for the adapter delete gate, verified by reverting the bypass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@Deepak-Kesavan Both re-review findings fixed in 040f205.

IsFrictionLessAdapterDelete — confirmed, fixed. Verified the asymmetry you pointed at: seven _is_service_account(request) bypasses in that file (lines 114, 131, 163, 194, 229, 243, 268), including IsFrictionLessAdapter immediately above, and the Delete variant had none while destroy routes to it. Added the bypass after the frictionless carve-out, as suggested.

I swept the rest rather than wait for a fourth round. All 13 _is_resource_owner call sites across both repos:

Site Covered by
permission.py 116, 133, 166, 212, 232, 246, 270 existing bypasses at 114, 131, 163, 194, 229, 243, 268
permission.py:287 this commit
tool_instance_helper.py:546, workflow_v2/permissions.py:43 a57f99b
prompt_studio/permission.py 27, 54 bypasses at 24, 51
prompt_studio_helper.py:102 unreachable — its only caller short-circuits on is_service_account before calling it

So the PR body's claim about the service account keeping org-wide access is now true rather than aspirational.

On the page-1 revert — reply is on that thread. Short version: you were right to correct it, and the contradiction was visible in my own comment, which said never-run rows sort last and then jumped to page 1 anyway. Reverted to handleListRefresh.

Regression test added for the delete gate, verified by reverting the bypass.

One caveat on verification: my local full-suite runs are unreliable right now — the Docker environment was wiped and the test-database lifecycle is thrashing, with the same failure profile on a clean baseline as with my changes. Per-app runs are clean and the new test is mutation-verified, but I am treating CI as the authority for the full suite rather than claiming a green local run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

✅ All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
✅ e2e-api-deployment e2e 3 0 0 0 20.5
✅ e2e-coowners e2e 1 0 0 0 1.7
✅ e2e-etl e2e 1 0 0 0 8.4
✅ e2e-login e2e 2 0 0 0 1.3
✅ e2e-prompt-studio e2e 1 0 0 0 11.6
✅ e2e-smoke e2e 2 0 0 0 1.3
✅ e2e-workflow e2e 1 0 0 0 20.3
❌ frontend unit 0 1 0 0 0.0
✅ integration-backend integration 555 0 0 26 55.4
✅ integration-connectors integration 1 0 0 7 7.9
✅ integration-workers integration 157 0 0 1 51.0
❌ ui e2e 0 1 0 0 0.0
✅ unit-backend unit 1276 0 0 1 35.2
✅ unit-connectors unit 63 0 0 0 8.7
✅ unit-core unit 115 0 0 0 1.6
✅ unit-platform-service unit 15 0 0 0 2.2
✅ unit-rig unit 120 0 0 0 3.9
✅ unit-runner unit 5 0 0 0 2.3
✅ unit-sdk1 unit 563 0 0 0 24.8
✅ unit-workers unit 1425 0 0 1 147.9
TOTAL 4306 2 0 36 406.1

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@Deepak-Kesavan

Copy link
Copy Markdown
Contributor

🤖 Automated re-review (pass 3) — clean

Re-reviewed 040f20546 + the main merge at 6a61ae6d2. No findings. Both pass-2 items verified fixed, and the sweep claim in your commit message checks out.

Verified:

  • IsFrictionLessAdapterDelete now carries _is_service_account in the same position as every sibling (permission.py:285-286). AdapterInstance.is_friction_less is default=False, so test_adapter_delete_admits_a_service_account reaches the bypass branch rather than short-circuiting on the frictionless carve-out — the test is not vacuous.
  • The sweep is accurate: all 13 _is_resource_owner( call sites in OSS carry a bypass, except prompt_studio_helper.py:102, whose only production caller returns early at :237 on is_service_account. The enterprise repo has zero such call sites on main, so "both repos" is covered.
  • ApiDeployment.jsx is a clean revert — byte-identical to its b2d830ed4 state, useCallback dropped from the import, no residue. Agreed that making a never-run deployment visible needs an ordering change and that is a separate product call.
  • The main merge brought nothing into backend/ beyond your fix; tenant_account_v2/migrations/ still has exactly one 0006_*, so no migration-number collision, and the six *_absorb_shared_users dependencies all still resolve.

One candidate raised and refuted, recording it so it does not come back: creating an adapter through a platform key then deleting it returns 500 CannotDeleteDefaultAdapter, because create writes the new adapter into the callers UserDefaultAdapter. Reproduced — but the control reproduces it identically for a plain human org admin. UserDefaultAdapter (views.py:283) and the destroy guard (:355-378) have no caller-type branching, so this is the long-standing default-adapter rule with its own remedy (POST /adapter/default_triad/), not a gap in this fix. Both lines are untouched by this PR.

Nothing blocking from this pass. Worth remembering Zipstack/unstract-cloud#1753 merges after this one.

🤖 Unstract PR review kit (Claude Code) · automated pass · findings are starting points, not verdicts

@kirtimanmishrazipstack
kirtimanmishrazipstack merged commit 11bde0e into main Sep 17, 2026
11 checks passed
@kirtimanmishrazipstack
kirtimanmishrazipstack deleted the UN-3853-co-ownership-resources-with-platform-key branch September 17, 2026 05:16
kirtimanmishrazipstack added a commit that referenced this pull request Sep 17, 2026
UN-3853 (#2274) extracted the "Owned By" label into the shared
``resolveOwnerDisplay`` helper. This branch had rewritten the same block in
the card view to read ``is_owner``, so the two collided in
``CardFieldComponents`` and on the import line in ``ResourceTable``.

Took the helper. It reads ``owner_emails``, so it sees co-owners -- the gap
this branch's version was written to close -- and it additionally labels
platform-key service accounts and resolves "Me" against the owner actually
shown rather than the viewer's own membership. ``OwnerFieldRow`` therefore
takes ``sessionDetails`` again, and both card configs pass it.

``is_owner`` is untouched elsewhere: it drives editability via
``canEditResource``, which is a separate question from whose name is on the
row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants