Skip to content

UN-3057 [FIX] Repair Prompt Studio projects left ownerless by the clone path - #2239

Open
pk-zipstack wants to merge 1 commit into
mainfrom
fix/clone-owner-membership
Open

UN-3057 [FIX] Repair Prompt Studio projects left ownerless by the clone path#2239
pk-zipstack wants to merge 1 commit into
mainfrom
fix/clone-owner-membership

Conversation

@pk-zipstack

@pk-zipstack pk-zipstack commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

Adds a data migration repairing Prompt Studio projects that were left with no owner by the clone path, plus the reusable helper behind it.

Paired with Zipstack/unstract-cloud#1721, which fixes the clone helper itself. This PR only repairs rows already written; that one stops new breakage.

Why

Since UN-2202 (3653b418c), created_by is audit-only: _is_resource_owner takes the membership branch for any resource exposing membershipsCustomTool does (models.py:195) — and never falls back to created_by.

The clone helper never created the OWNER ResourceMembership row, so every project cloned after 0009_absorb_shared_users ran is ownerless. That backfill seeded OWNER rows from created_by for everything existing at the time, which is exactly why the regression window starts there.

The symptom users hit: the clone is visible (it copies the parent's shared_to_org) and profiles can still be created (that route uses the looser IsOwnerOrSharedUserOrSharedToOrg), but IsParentToolOwner denies DELETE/PUT/PATCH — "unable to delete the LLM profile of a cloned project" returned 403.

How

  • repair_ownerless_owner_rows() grants created_by an OWNER row on resources with zero OWNER rows. A creator deliberately replaced by a co-owner is not resurrected; null creator / null organization are skipped. Idempotent.
  • It iterates _base_manager, not objects — several resources' default manager is org-scoped by UserContext, which is unset during a migration and would silently filter every row out and repair nothing. Same guard tenant_account_v2.signals already documents.
  • Migration 0011 applies it to CustomTool and reverses to a no-op.

Testing

Three regression tests pin repair / leave-alone / skip.

Verified red-green rather than assumed: with the helper body stubbed to return 0, the behaviour test fails and the two guard tests still pass (they assert the repair must not act).

integration-backend: 4 passed (this PR's tests + the cloud clone test)
unit-backend:        842 passed, 1 skipped
integration-backend: 837 passed, 29 skipped

One pre-existing local failure unrelated to this change: plugins/notification/tests/test_sharing_notification.py errors with ModuleNotFoundError: No module named 'sendgrid' (chain: test → sharing_notification → email_service → sendgrid; the package is absent from the local venv).

Reviewer notes

Two adjacent defects in the same clone path were found but deliberately not fixed here, to keep this scoped to the reported bug — worth separate tickets:

  1. Cloned ProfileManager.created_by still points at the original project's owner, so validate_profile_manager_owner_access evaluates adapter access against the wrong user.
  2. The clone inherits shared_to_org=True from the parent, so a clone of a shared project is silently org-wide.

🤖 Generated with Claude Code

…ne path

Since UN-2202, `_is_resource_owner` consults only ResourceMembership OWNER
rows for resources that expose `memberships` (CustomTool does) and no longer
falls back to `created_by`. The Prompt Studio clone path never created that
row, so every project cloned after 0009_absorb_shared_users ran has no owner
at all: still visible (the clone copies the parent's `shared_to_org`) and
profiles can still be created, but `IsParentToolOwner` denies every mutation
on them — deleting an LLM profile returned 403.

The clone helper itself is fixed in unstract-cloud; that stops new breakage
but cannot help rows already written. This adds the repair:

- `repair_ownerless_owner_rows()` grants `created_by` an OWNER row on
  resources that have zero OWNER rows. Only ownerless resources are touched,
  so a creator deliberately replaced by a co-owner is not resurrected, and a
  null creator or null organization is skipped. Idempotent.
- It iterates `_base_manager`: several resources' default manager is
  org-scoped by `UserContext`, which is unset during a migration and would
  silently filter every row out. Same guard `tenant_account_v2.signals` uses.
- Migration 0011 applies it to CustomTool; reverses to a no-op.

Regression tests pin all three branches (repair, leave-alone, skip). Verified
red-green: with the helper body stubbed to a no-op the behaviour test fails
and the two guard tests still pass.

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

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds an idempotent data migration and reusable helper intended to restore OWNER memberships for Prompt Studio projects left ownerless by the clone path.

  • Scans CustomTool rows through the unscoped base manager and skips null creators or organizations.
  • Leaves resources with any existing OWNER membership unchanged.
  • Adds regression tests for repair, existing-owner, and null-creator cases.

Confidence Score: 4/5

The repair should be fixed before merging because eligible projects can remain ownerless when their creator already has a VIEWER membership.

The migration identifies such projects as ownerless but returns the creator's existing membership unchanged, leaving ownership-gated operations inaccessible after the intended repair.

Files Needing Attention: backend/tenant_account_v2/migrations/_membership_backfill.py, backend/prompt_studio/prompt_studio_core_v2/tests/test_ownerless_owner_repair.py

Important Files Changed

Filename Overview
backend/tenant_account_v2/migrations/_membership_backfill.py Adds the reusable ownerless-resource repair, but fails to promote an existing creator VIEWER membership to OWNER.
backend/prompt_studio/prompt_studio_core_v2/migrations/0011_repair_ownerless_custom_tools.py Correctly wires the repair helper into the Prompt Studio migration graph with a no-op reverse.
backend/prompt_studio/prompt_studio_core_v2/tests/test_ownerless_owner_repair.py Covers ordinary repair and skip cases but omits the existing-creator-VIEWER case that exposes the repair defect.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Migration 0011] --> B[Load CustomTool rows with non-null creator]
    B --> C{Organization present?}
    C -- No --> D[Skip]
    C -- Yes --> E{Any OWNER membership?}
    E -- Yes --> F[Leave unchanged]
    E -- No --> G[get_or_create creator membership]
    G --> H{Membership already exists?}
    H -- No --> I[Create OWNER]
    H -- Yes, VIEWER --> J[VIEWER remains unchanged]
    J --> K[Tool remains ownerless]
Loading

Fix All in Greploop

Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
backend/tenant_account_v2/migrations/_membership_backfill.py:109-114
**Existing viewer remains ownerless**

When an ownerless tool's creator already has a VIEWER membership, `get_or_create` returns that row without applying the OWNER default, causing ownership-gated PUT, PATCH, and DELETE operations to continue returning 403 after the repair.

```suggestion
        _, created = Membership.objects.update_or_create(
            content_type=content_type,
            object_id=object_id,
            user_id=resource.created_by_id,
            defaults={"role": OWNER, "organization_id": resource.organization_id},
        )
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "UN-3057 [FIX] Repair Prompt Studio proje..." | Re-trigger Greptile

Comment on lines +109 to +114
_, created = Membership.objects.get_or_create(
content_type=content_type,
object_id=object_id,
user_id=resource.created_by_id,
defaults={"role": OWNER, "organization_id": resource.organization_id},
)

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.

P1 Existing viewer remains ownerless

When an ownerless tool's creator already has a VIEWER membership, get_or_create returns that row without applying the OWNER default, causing ownership-gated PUT, PATCH, and DELETE operations to continue returning 403 after the repair.

Suggested change
_, created = Membership.objects.get_or_create(
content_type=content_type,
object_id=object_id,
user_id=resource.created_by_id,
defaults={"role": OWNER, "organization_id": resource.organization_id},
)
_, created = Membership.objects.update_or_create(
content_type=content_type,
object_id=object_id,
user_id=resource.created_by_id,
defaults={"role": OWNER, "organization_id": resource.organization_id},
)

Knowledge Base Used: Django Core Scaffold: Settings, Routing, Auth, Tenancy

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/tenant_account_v2/migrations/_membership_backfill.py
Line: 109-114

Comment:
**Existing viewer remains ownerless**

When an ownerless tool's creator already has a VIEWER membership, `get_or_create` returns that row without applying the OWNER default, causing ownership-gated PUT, PATCH, and DELETE operations to continue returning 403 after the repair.

```suggestion
        _, created = Membership.objects.update_or_create(
            content_type=content_type,
            object_id=object_id,
            user_id=resource.created_by_id,
            defaults={"role": OWNER, "organization_id": resource.organization_id},
        )
```

**Knowledge Base Used:** [Django Core Scaffold: Settings, Routing, Auth, Tenancy](https://app.greptile.com/zipstack/-/custom-context/knowledge-base/zipstack/unstract/-/docs/backend-django-core.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@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.3
e2e-coowners e2e 1 0 0 0 1.5
e2e-etl e2e 1 0 0 0 8.6
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.6
e2e-smoke e2e 2 0 0 0 1.4
e2e-workflow e2e 1 0 0 0 16.3
integration-backend integration 270 0 0 26 44.2
integration-connectors integration 1 0 0 7 8.0
integration-workers integration 140 0 0 1 50.2
unit-backend unit 998 0 0 1 40.4
unit-connectors unit 63 0 0 0 10.4
unit-core unit 33 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 117 0 0 0 5.6
unit-sdk1 unit 480 0 0 0 24.1
unit-workers unit 1335 0 0 1 88.8
TOTAL 3463 0 0 36 329.7

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
  • 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

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.

1 participant