-
Notifications
You must be signed in to change notification settings - Fork 716
UN-3315 [FIX] Honour shared_to_org for Prompt Studio prompt edits - #2259
UN-3315 [FIX] Honour shared_to_org for Prompt Studio prompt edits #2259hari-kuriakose wants to merge 19 commits into
Conversation
Projects shared via "Share with everyone" set shared_to_org on the parent CustomTool. IsOwnerOrSharedUserOrSharedToOrg already honours that flag, so such a project was visible to the whole org -- but PromptAcesssToUser, which guards prompt/note CRUD, never checked it. The result was that only the owner could edit prompts in a project shared with everyone. Adds the shared_to_org check to PromptAcesssToUser so prompt access matches the project access the share already granted. UN-3542 (last org admin demotion) needs no change: the _ensure_not_last_admin_demotion guard already landed on main in 2f996d3 (#2048) and is wired into both add_user_role and remove_user_role. The ticket is stale and should be closed rather than reimplemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sXFBEu2GHatXq2CV2ShPF
Follow-up to 79c985b, which made shared_to_org grant access to a project's prompts. PromptAcesssToUser is the sole permission class on ToolStudioPromptView, which routes delete->destroy (prompt_studio_v2/urls.py:13), so that grant covered deletion too. The parent CustomTool's own destroy is owner-only (IsOwner in CustomToolViewSet.get_permissions), which left any org member able to delete every prompt inside a project they could not themselves delete. Product decision: "share with everyone" means view + edit, not delete. Adds IsPromptParentToolOwner and splits get_permissions so only destroy uses it. Reads and edits keep the widened class, matching CustomToolViewSet, which already routes update/partial_update on the tool itself through IsOwnerOrSharedUserOrSharedToOrg. Kept as a separate class rather than teaching IsParentToolOwner to read both prompt_studio_tool and tool_id: a shared authorization class that accumulates per-caller special cases is how these gates drift apart. Also addresses two review findings on 79c985b: - getattr(tool, "shared_to_org", False) -> tool.shared_to_org. tool is always a CustomTool, where the field is a non-nullable BooleanField, so the default was unreachable and would only mask a renamed field by silently denying. Matches IsOwnerOrSharedUserOrSharedToOrg, which reads it directly. - The inline comment claimed prompts "stayed read-only for everyone except the owner". That was imprecise: VIEWER and group-share already granted write. Narrowed to the shared_to_org-only user, who genuinely had no access. Known gap, unchanged by this commit: reorder_prompts is a collection-level POST, so get_object() never runs and neither permission class gates it (see prompt_studio_v2/helper.py:28). tool_instance_v2/views.py:196-205 has the pattern that closes it. Needs its own change. Untested: the backend suite does not run in this checkout -- settings import fails at backend/settings/base.py:63 on CELERY_BROKER_BASE_URL=None, and conftest.py notes backend tests do not run under tox in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
Follow-up to 943b3a0, whose comments overstated what the destroy split achieves and asserted an invariant the models contradict. 1. "Deletion is not included" / "not deletable by it" was false. The bulk sync_prompts route on PromptStudioCoreView rip-and-replaces every prompt in a project and admits org-shared users -- only destroy and the co-owner actions are IsOwner-gated there, and CustomTool.objects.for_user admits shared_to_org=True so no 404 shields it. The split closes per-prompt DELETE and nothing else. Both comments now say so and point at the surviving route rather than implying it does not exist. 2. "tool is always a CustomTool" contradicted the model (tool_id is a nullable SET_NULL FK) and the IsPromptParentToolOwner docstring 25 lines below, which correctly documents the orphan case. A maintainer trusting it would drop the `tool is not None` guard and turn a clean 403 into an AttributeError 500. Dropped the sentence; the "a default masks a renamed field" rationale is true on its own and is what the direct read actually rests on. 3. The divergence docstring cited only the precedent that supports the widening (CustomToolViewSet) and omitted the nearer sibling that chose the other way -- ProfileManagerView gates every mutation behind IsParentToolOwner. Two sub-resources of one parent answer "does a share grant edit?" differently; the docstring now names that rather than reading as though it were settled. No behaviour change: comments and docstrings only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
4d8a381 fixed one over-broad closing claim and introduced another. It said ProfileManagerView "gates every mutation behind IsParentToolOwner, so a shared user can edit a project's prompts but not its profiles". Both halves are false in the direction that stops someone hardening those routes: - IsParentToolOwner implements only has_object_permission. DRF never calls it for create (there is no object yet), and ProfileManagerView.create never calls check_object_permissions or get_object, so that route is ungated. The sibling IsParentDeploymentOwner docstring documents this exact DRF gap and notes its view compensates by handing the parent to check_object_permissions -- ProfileManagerView does not. - create_profile_manager and make_profile_default on PromptStudioCoreView both fall through to IsOwnerOrSharedUserOrSharedToOrg, which admits shared_to_org. Narrowed to the three routes IsParentToolOwner actually gates, and named the gap rather than implying profiles are locked down. No behaviour change: docstring only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
sync_prompts is a rip-and-replace that deletes every prompt in a project
before importing, and PromptStudioCoreView.get_permissions sent only
destroy/add_co_owner/remove_co_owner to IsOwner(). Everything else, this
route included, fell through to IsOwnerOrSharedUserOrSharedToOrg, which
admits shared_to_org -- and CustomTool.objects.for_user admits those tools
too, so no 404 shielded it. Any org member of a shared project could wipe
every prompt in it, outputs riding along via CASCADE.
Two changes, closing two different things:
1. sync_prompts joins the IsOwner() list. Deleting every prompt is
owner-level destruction; UN-3315 settled that a share grants view + edit,
not delete.
2. An empty-payload guard in PromptStudioHelper.sync_prompts, ahead of the
transaction. This one is a correctness fix as much as a security one: the
docstring says "deletes all existing prompts and creates new ones", but
the create half is a loop over prompts_data that never runs on an empty
list. So {"prompts": []} deleted everything, imported nothing, and
returned success -- the code did not do what it documented. Raises
ValueError to match the default_profile guard immediately below it.
The guard sits before `with transaction.atomic()` deliberately: a
rollback is not a refusal, and the point is that the delete never
executes. Rejects empty only -- a non-empty list is a legitimate replace
and still works.
Honest scope: this closes the session-user path via IsOwner(), and the
empty-payload wipe on both paths via the guard. A read_write platform API
key can still call sync_prompts and replace prompts wholesale with a
non-empty list -- service accounts short-circuit ahead of every check, and
this route declares no required_method tier the way mcp_server does. Known
and accepted; deliberately not addressed here.
Also updates the two comments 0a96979 left behind, which said the
sync_prompts hole was open. It is not, for a session user, as of this
commit.
Untested: the backend suite does not run in this checkout (settings import
fails on CELERY_BROKER_BASE_URL=None).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
75fd2ac added a guard refusing {"prompts": []} on the premise that an empty rip-and-replace deleting everything and importing nothing was the code failing its own contract. That premise was wrong. test_prompt_studio_author.py:216 -- test_sync_prompts_clear_bumps_tool_ modified_at -- calls sync_prompts(tool, {"prompts": []}, user) and asserts prompts_deleted == 1, with a docstring describing a prompts-clearing sync as behaviour that must bump modified_at. Clearing every prompt by syncing an empty list is existing, intentional, test-asserted behaviour, not an accident. The guard broke a published capability and would have failed that test the moment anyone could run the suite. Reverts the guard only. The IsOwner() gating from 75fd2ac stands: the exposure was always a permissions question, and clearing a project's prompts is now owner-only like every other deletion path. Docstring updated to record the empty-list clear as supported, so the next reader does not re-derive it as a defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
reorder_prompts is declared @action(detail=True) but routed at the collection path (prompt/reorder/, urls.py:22) with a signature taking no pk -- the target comes from prompt_id in the request body. DRF therefore never calls get_object(), so has_object_permission never fires, and neither permission class defines has_permission, so BasePermission's default True applied. The helper then fetched on the raw manager (helper.py:28, no for_user scoping) and mutated every sibling row sharing the derived tool_id. Net: any authenticated user could renumber the prompts of any project in any organization, shared or not. Resolves the prompt and calls check_object_permissions explicitly, the same shape ToolInstanceViewSet.reorder uses for the identical collection-POST problem (tool_instance_v2/views.py:196-205). Two details worth stating: - Gated as an EDIT, not a deletion. reorder_prompts resolves to PromptAcesssToUser, so org-shared members can reorder, per UN-3315's view + edit ruling. Routing it to owner-only would have over-restricted. - Org scoping goes through the parent tool. ToolStudioPrompt is a plain BaseModel with no organization field and no for_user manager, so filtering on CustomTool.objects.for_user(...) is what makes a cross-org prompt_id 404 before the permission check rather than after it. A missing prompt_id now raises a 400 rather than reaching the controller, which previously surfaced it as a serializer error further in. Untested: the backend suite does not run in this checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
ProfileManagerView.get_permissions already listed create under IsParentToolOwner(), but that class defined only has_object_permission, which DRF never calls for create -- there is no object yet. So the docstring's guarantee, "Mutations require ownership of the parent tool", was false for exactly one action: any org member could create a ProfileManager against any tool by naming it in the payload. Adds has_permission, reading the parent from the request payload (prompt_studio_tool; ProfileManagerSerializer uses fields = "__all__", so it is present on create) and applying the same ownership test as has_object_permission. Service-account and org-admin fallbacks preserved in both. has_object_permission is untouched, so update/partial_update/ destroy keep resolving through get_object() as before. Non-create actions return True from has_permission and are still decided by has_object_permission -- the collection gate must not double-gate an action whose object check already covers it. Malformed input denies rather than crashes: request.data may be a QueryDict, a list, or unparsed garbage, and the pk is a UUID, so a non-dict body, an absent prompt_studio_tool, or an unparseable id returns False and lets the serializer raise its own 400. Django's ValidationError is what a bad UUID raises here, hence the import. Deliberately extends the shared class rather than adding a sibling, which is the opposite of the IsPromptParentToolOwner call two commits back. Not a contradiction: there, the shared class would have had to juggle two different parent FK names (prompt_studio_tool vs tool_id) for two consumers. Here there is one consumer -- ProfileManagerView is the only non-docstring reference to IsParentToolOwner in the tree -- and what is being added is a method the class was always missing. Untested: the backend suite does not run in this checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
...aces Three prose defects from 5bc4ee9 and c35fbe7, all in the closing direction -- each told a reader a surface was shut when it is not. 1. "ProfileManagerView routes every mutation through IsParentToolOwner, so a shared user can edit a project's prompts but not its profiles." False twice over. ProfileManagerView routes no create at all (its urls.py has a single detail path), and profiles are created via PromptStudioCoreView.create_profile_manager, which falls through to IsOwnerOrSharedUserOrSharedToOrg and admits org-shared users -- as does make_profile_default. 75fd2ac said exactly this and was correct; 5bc4ee9 deleted it and replaced it with the false claim. Restored. 2. "A read_write API key still reaches both." Only one. Per the tier table in _is_service_account, read_write covers POST/PUT/PATCH and full_access adds DELETE, so a read_write key is refused on per-prompt destroy (an HTTP DELETE) and reaches only sync_prompts (a POST). 3. "Two deletion paths remain open to a non-owner" then listed one route twice, the second entry concluding it is in fact owner-gated. Now states the one path, with the empty-list clear recorded separately as supported behaviour rather than as a hole. No behaviour change: comments and docstrings only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
Fourteen tests over the three mechanisms enforcing "a share grants view and edit, not delete". Every one is mutation-checked: the fix was reverted, the test observed to fail, then restored. 1. destroy split reverted to a flat permission_classes -> fails 2. split widened to gate update/partial_update too -> fails (x2) 3. check_object_permissions deleted from reorder_prompts -> fails (x2) 4. "sync_prompts" removed from the IsOwner list -> fails 5. the UN-3315 shared_to_org branch removed -> fails Mutation 2 is the over-restriction guard. UN-3315 grants edit; a split that routed every mutation to the owner-only class would pass the deletion tests while silently removing the capability this work exists to add. Mutation 5 covers the same axis from the other side. Imports the real modules rather than slicing bodies out with tests_common.source_extraction, as the sibling registry suite does. That technique's own docstring records why it cannot serve here: bodies are exec-ed out of context, so unreachable code is indistinguishable from wired code -- and "the hook is never reached" IS the reorder_prompts defect. A source-extracted test of PromptAcesssToUser.has_object_permission would have passed both before and after that fix. The same docstring notes the premise behind extraction no longer holds (Django is importable in this tier), and importing also avoids its other two sharp edges. Correcting the record: four earlier commits in this series say "the backend suite does not run in this checkout". That is true only of the DB-backed tier. The permission tier is deliberately DB-free and runs in about a second; the blocker was that settings vars must be exported into the environment, not merely written to test.env. Those notes were wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
...eachable 70ff027 added has_permission to close what looked like an ungated create: ProfileManagerView.get_permissions lists "create" under IsParentToolOwner, and that class defined only has_object_permission, which DRF never calls for create. The second half is true. The first is not. ProfileManagerView exposes no create route. prompt_profile_manager_v2/ urls.py binds exactly one path -- profile-manager/<uuid:pk>/ -- to {get: retrieve, put: update, patch: partial_update, delete: destroy}. No collection POST, and no router registration anywhere in the tree; the only other references to the viewset are that import and a docstring. So self.action is never "create", the method always took its non-create early return, and it closed nothing. The "create" entry in that get_permissions list is itself dead, but it predates this PR. The real gap is on PromptStudioCoreView: create_profile_manager (views.py:958) and make_profile_default both fall through get_permissions to IsOwnerOrSharedUserOrSharedToOrg, so an org-shared member can create a profile on someone else's project and change which profile is default. Deliberately not addressed here -- gating them narrows a shipped capability on a viewset outside this PR's scope, which is a product decision. make_profile_default carries a second, separate defect worth its own ticket: at views.py:401-407 it clears is_default across the tool's profiles and then resolves the promoted profile with ProfileManager.objects.get(pk=request.data["default_profile"]) on the raw manager, so the profile being promoted is never checked against the tool it is being made default for. No test accompanied 70ff027 and none is removed here. A test would have constructed a view with action="create", passed, and mutation-checked correctly while the production path stayed ungated -- vacuous in exactly the way that hides this class of defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
Behaviour-preserving cleanup after the fixes settled. Tests re-run and the
mutation checks re-confirmed: removing the shared_to_org branch still fails
1 test, removing check_object_permissions still fails 2.
Prose: the PromptAcesssToUser docstring had accumulated a routing table
stating, from inside a permission class, which actions two other viewsets
gate -- facts already stated at both enforcement sites, so three copies
that drift independently. Cut to the one line a reader of this class needs.
Same for the ProfileManagerView rebuttal and the empty-prompts note, whose
subject is payload validation on a route this class does not guard. The
read_write API-key gap stays: it is a load-bearing accepted-risk warning,
not restatement. The duplicate of it in views.py becomes a pointer.
Queries: hoisted `tool.shared_to_org` above the owner and viewer checks. It
is a free attribute read and each branch it now precedes runs an .exists()
query, so the org-share path -- the one UN-3315 exists to serve -- saves up
to two. Order is not otherwise observable: the method is a plain OR of
side-effect-free predicates. Added select_related("tool_id") to the reorder
lookup, since the permission class dereferences that FK immediately and the
parent is already joined by the filter.
Not taken, deliberately:
- Threading the fetched prompt through PromptStudioController into
reorder_prompts_helper to kill its duplicate SELECT. Real (one wasted
round-trip per reorder) but it changes two signatures outside this diff
and makes the controller's DoesNotExist branch dead.
- Extracting a parent-owner base class over IsParentToolOwner /
IsRegistryToolOwner / IsPromptParentToolOwner. The duplication is real
and predates this PR; consolidating touches two unchanged auth classes,
which is not scope for a permissions fix.
- Switching the inline service-account/admin checks to the shared
_is_service_account / _is_organization_admin helpers. Right in principle
and would pick up the per-request admin cache, but all three classes in
this file hand-roll them; changing one creates divergence rather than
removing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
make_profile_default cleared is_default across the target tool's profiles and then resolved the promoted profile with ProfileManager.objects.get(pk=request.data["default_profile"]) -- the raw manager, with no check that the profile belongs to that tool. Promoting a profile from another tool therefore succeeded, and because the clear had already run, the target was left with NO default of its own and a foreign profile marked default for it. Resolves the profile first, scoped to prompt_studio_tool, and only then clears. Order matters as much as the scoping: 404-ing after the clear would still leave the tool without a default, so a refused promotion must modify nothing. The test pins the ordering, not just the filter -- moving the clear back ahead of the lookup fails it. Also replaces the bare request.data["default_profile"] KeyError (a 500 when the field is absent) with a 400. No capability change: who may call this route is unchanged. Gating it and create_profile_manager to IsOwner was proposed and reversed by the user; org-shared members keep both, as they ship today. Restores two docstring passages that 7d4f281 cut as redundant. They are not: that sync_prompts with an empty prompts list clears a project BY DESIGN is the conclusion that cost a wrong guard, a commit and a forward revert, and the next reader will re-propose that guard without it. Now says plainly not to. The note that sync_prompts is IsOwner-gated is likewise true and load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
Three tests, each mutation-checked: A1 drop `prompt_studio_tool=` from the lookup -> fails A2 restore the bare request.data[...] KeyError -> fails A3 move the is_default clear back ahead of the lookup (the original bug's ordering) -> fails A3 is the one worth having. A test that only asserted the filter would pass against a version that 404s after wiping the tool's default -- which is the same broken end state, reached a different way. It asserts the clear did not run when the promotion is refused. Extends the over-restriction tripwire to create_profile_manager and make_profile_default, asserting they still resolve the share-aware class. Gating them was proposed and reversed by the user, so this pins the reversal: current behaviour, not the abandoned change. A future edit that sweeps every action to owner-only now fails here. 19 pass in the file; 129 across the permission tier, unchanged from before these commits apart from the 3 added here plus the 4 extra parametrized cases. The 53 Postgres errors in the wider run are pre-existing and identical at the pre-PR baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
PromptAcesssToUser admits org-shared members to update/partial_update while
IsPromptParentToolOwner denies them destroy -- but that gate reads the
STORED parent, which DRF loads before the update is applied. tool_id is
client-writable (fields="__all__", and the model FK leaves editable=True
unlike created_by/modified_by), so a denied user could:
PATCH /prompt/<id>/ {"tool_id": "<a tool they own>"} -> 200
DELETE /prompt/<id>/ -> 200
Two requests, each passing every check, and Alice's prompt is gone. Every
test this PR added passes throughout.
Moving a prompt out of a project removes it from that project -- a deletion
from the losing side -- so it now requires what destroy requires, checked
against the EXISTING parent. Checking the NEW parent would pass trivially:
the attacker's destination is a tool they already own, and the harm is the
prompt leaving the original project regardless of where it lands.
Three behaviours preserved deliberately, each with a test:
- An unchanged tool_id is a no-op, not a reparent. The Prompt Studio UI
PATCHes one field at a time (DocumentParser.jsx builds {[name]: value}),
so nothing in-tree is affected either way, but a payload echoing the
parent back must not 403.
- An owner reparenting between tools they own still works.
- null is a reparent, not a no-op. Orphaning the row hides it from
everyone, its owner and org admins included, once the org filter's INNER
JOIN excludes it.
A deliberate tightening, not a bug fix: a request that succeeds today will
403. Verified no legitimate caller does this -- the only two PATCH callers
in frontend/src hit tool_instance/ and workflow/endpoint/, and the prompt
PATCH never carries tool_id.
This NARROWS the bypass; it does not make tool_id unwritable. The complete
fix is a read-only field on update, declined on API-contract grounds
because DRF silently ignores read-only input -- a legitimate reparent would
get 200 and no effect. Preserved at:
unstract-pr2259-pending/fix1-tool_id-readonly-DECLINED.patch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
get_queryset returned ToolStudioPrompt.objects.all() when unfiltered. list never calls get_object(), and neither permission class defines has_permission, so BasePermission's default True applied and nothing object-level fired: any org member could enumerate every prompt in the organization via GET /prompt/. OrganizationFilterBackend still blocked cross-org access, so the exposure was intra-org, and the list serializer limits it to prompt keys and sequence numbers -- but CustomTool.for_user deliberately hides those projects, and this route handed back their contents anyway. Both branches were open, not just the fallback: the filtered branch takes tool_id straight from the query string with no ownership check of its own, so scoping only the .all() path would have left the branch the UI actually uses exactly as it was. A test pins each. Scoped through the parent because ToolStudioPrompt has no organization field and no for_user manager -- the same route reorder_prompts already takes. This matches the sibling PromptStudioCoreView.get_queryset, which has always scoped with CustomTool.objects.for_user. Not a contract change: the response shape is identical and only the row set narrows. It is a deliberate tightening rather than a bug fix -- a caller listing prompts of a project they cannot reach stops seeing them. Note this also narrows get_object() for the detail routes, turning a 403 into a 404 for unreachable tools. That is an improvement (less enumeration) and does not weaken the deletion gate: for_user ORs in shared_to_org, so a shared member still reaches the prompt and still gets a real 403 from IsPromptParentToolOwner on destroy -- which the existing tests pin. Also corrects the get_permissions comment, which claimed reads honour project sharing. That was true for retrieve and false for list until this change; it now names both levels that enforce it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
format_suffix_patterns generates a `.json` variant of every route in this URLconf, and DRF forwards the captured `format` kwarg to the handler. reorder_prompts took only `request`, so the suffixed route raised TypeError -- a 500 raised during dispatch, before the permission check was reached. Absorbs the kwarg in the signature, which is how the DRF mixins handle the same thing (they take *args, **kwargs). Dropping the route from format_suffix_patterns was the alternative, but that call wraps every pattern in the file, so it would have changed the URLconf for routes this PR has no business touching. Fixes a broken contract rather than changing a working one: the route returns 200 where it previously 500'd, and the unsuffixed route is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
Fifteen tests across the three fixes, every one mutation-checked -- the fix
reverted, the failure observed, the file verified restored:
neuter the reparent gate (never deny) -> 2 failed
treat null tool_id as a no-op -> 1 failed
unscope get_queryset entirely -> 2 failed
scope ONLY the unfiltered branch -> 1 failed
remove **kwargs from reorder_prompts -> 1 failed
Two are over-restriction guards rather than under-restriction ones, which
is the half that is easy to omit: an owner must still be able to reparent,
and an unchanged tool_id must stay a no-op so the UI's field-level PATCH
keeps working. Both fail if the gate is widened to catch every update.
The null case earns its own test. Treating {"tool_id": null} as "unchanged"
looks reasonable and orphans the row, which the org filter's INNER JOIN
then hides from everyone including org admins -- a delete by another name.
The two list tests cover the branches separately on purpose: scoping the
.all() fallback alone leaves the filtered branch -- the one the UI uses --
exactly as open as before, and a single test over both would not have
caught that.
Direct imports rather than tests_common.source_extraction. That technique
execs method bodies out of context, so unreachable code is indistinguishable
from wired code -- and "the hook never fires" is precisely the class of bug
these fixes address.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
hari-kuriakose
commented
Aug 31, 2026
Follow-up raised: UN-4054 — tool_id remains client-writable
Tracking the residual half of the reparenting Critical found by the cold read on this PR: https://zipstack.atlassian.net/browse/UN-4054 (Medium).
What this PR closes. 636cedfa4 gates a reparent on the parent it leaves: in ToolStudioPromptView.update, if tool_id is present and differs from the stored value (null counts as a move), it requires IsPromptParentToolOwner against the existing parent. Moving a prompt out of a project is a deletion from that project's side, so it now needs what destroy needs, and it refuses visibly with a 403.
What stays open. tool_id is still a writable serializer field, so any other writer of that FK is ungated. The complete fix is one line — read_only_fields = ["tool_id"] — and it was deliberately declined, not missed: DRF ignores a read-only field rather than rejecting it, so a client legitimately sending tool_id on PATCH would get a 200 with no effect. A silent no-op was judged worse than a visible refusal, and it would change a published API contract.
Note for whoever picks UN-4054 up: a blanket read_only_fields would silently orphan every newly created prompt — create_prompt shares this serializer and takes tool_id from the request body, with nothing injecting it server-side. Any fix must be conditional on the update path. That analysis is preserved with the declined patch and summarised in the ticket.
Follow-up from the standardized review of this PR.
Quality Gate Passed Quality Gate passed
Issues
3 New issues
0 Accepted issues
Measures
0 Security Hotspots
0.0% Coverage on New Code
0.0% Duplication on New Code
|
| Filename | Overview |
|---|---|
| backend/prompt_studio/permission.py | Adds organization-sharing support to prompt read/edit authorization and introduces a parent-owner gate for deletion without exposing a reachable cross-organization path. |
| backend/prompt_studio/prompt_studio_core_v2/views.py | Makes destructive prompt synchronization owner-gated and validates default-profile ownership before changing persisted defaults. |
| backend/prompt_studio/prompt_studio_v2/views.py | Splits edit and delete permissions, scopes prompt resolution to reachable tools, explicitly authorizes reorder targets, and prevents shared users from reparenting prompts out of projects. |
| backend/prompt_studio/prompt_studio_v2/tests/test_prompt_permission_guards.py | Adds comprehensive regression coverage for the changed authorization and object-scoping behavior. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
R[Prompt request] --> Q{Action}
Q -->|Read or edit| V[Resolve through requester-visible parent tools]
V --> S{Owner, viewer, group share, org share, or org admin?}
S -->|Yes| A[Allow]
S -->|No| D[Deny]
Q -->|Delete prompt| O{Parent owner or org admin?}
O -->|Yes| A
O -->|No| D
Q -->|Bulk sync| B{IsOwner permission passes?}
B -->|Yes| A
B -->|No| D
Reviews (1): Last reviewed commit: "Merge branch 'main' into un-sprint4-B-pe..." | Re-trigger Greptile
Unstract test resultsPer-group results
Critical paths
|
@chandrasekharan-zipstack
chandrasekharan-zipstack
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Standardized review — High-severity findings only
Verdict: REQUEST CHANGES — Critical 0 · High 9 · Medium 14 · Low 8 · Lenses 16/16.
This review posts only the 9 High findings. The 14 Medium and 8 Low are held back to keep the thread actionable; happy to post them on request.
Test execution (verified, not assumed): the suite runs — 27 passed in 0.68s under the CI env (tests/groups.yaml:104-113, unit-backend), and CI does collect it. Bare pytest from backend/ fails collection because backend/test.env is absent — pre-existing ergonomics, not this PR.
On your reviewer note — the widening is right, and the description undersells it
You asked for a second opinion on whether org-wide prompt edit matches intent. It does, and the case is stronger than the body makes it: prompts were the anomaly, not the fix.
shared_to_orgalready conferred write on the parent object —PromptStudioCoreView.get_permissions(prompt_studio_core_v2/views.py:157-170) routesupdate/partial_updatethroughIsOwnerOrSharedUserOrSharedToOrg. An org-shared member could already edit the project itself. Prompts being read-only inside a project you can rename was the inconsistency.create_promptis completely ungated today (see H1 below). "Org members can write prompts" is not new; this PR makes the coherent subset of it work.- The delete carve-out is the right line, correctly drawn:
destroy→IsPromptParentToolOwner,sync_prompts→IsOwner.
So this is not a genuine broadening of the trust model — it closes a gap between two halves of a share that were already inconsistent. Saying it that way in the description removes the reviewer anxiety you were anticipating.
Blast radius. Every org-shared project in every tenant, immediately on deploy; no flag, rollback is a revert. But the incremental radius is narrower than it looks. What changes: org-shared members gain update/partial_update/retrieve/list/reorder on existing prompt rows. What they do not gain: destroy — and they lose sync_prompts, a tightening (see H6).
H1 — [High] [Lens 2, 4] create_prompt has the exact collection-POST hole this PR fixes for reorder_prompts, left wide open
Posted here rather than inline: the code is outside this PR's diff.
- Location:
backend/prompt_studio/prompt_studio_core_v2/views.py:955-968(routeurls.py:100-104; gateviews.py:157-170) - Failure mode:
@action(detail=True)but never callsself.get_object().IsOwnerOrSharedUserOrSharedToOrgdefines onlyhas_object_permission, soBasePermission.has_permissionreturnsTrueand nothing is checked. The<uuid:pk>is bound and never used; the parent comes entirely fromrequest.data["tool_id"], andToolStudioPromptSerializerisfields = "__all__". Any authenticated org member can POST with their own tool's UUID in the path and any project's UUID in the body, injecting a prompt into a project they have no share on. It then runs against the owner's LLM profile and documents. create_profile_manager(:971-987) is the same pattern with a partial mitigation — it callsself.get_object()only when the body omitsprompt_studio_tool, so a body that supplies it bypasses the check too.- Evidence: this PR's own comment one function over diagnoses this mechanism —
prompt_studio_v2/views.py:128-134. Noperform_createoverride onPromptStudioCoreView. Cross-org is blocked (DRF resolves the FK via the org-filteredCustomTool._default_manager); same-org is not. - Fix:
tool = self.get_object()at the top ofcreate_prompt, then forceserializer.validated_data["tool_id"] = toolbeforeperform_create. Makecreate_profile_manager'sget_object()unconditional. - Confidence: High. Pre-existing, not introduced here — which is why High and not Critical: merging does not cause it. But it sits in a file this PR edits, it is the same bug class the PR closes elsewhere, and it defeats the write-side model the PR's docstrings assert. The PR carefully gates reparenting a prompt out of a project while moving one in, to a project you cannot see, is free. If intra-org project privacy is a security boundary — and this PR presupposes it is — this deserves its own ticket at Critical priority.
H6 — [High] [Lens 1, 16] The PR description describes a different change and inverts a security-relevant claim
Posted here rather than inline: the finding is about the PR body.
- Failure mode: the body says "One file, +9/−2"; the diff is 5 files, +736/−13. More seriously it states "Known gap, not closed here: the bulk
sync_promptsroute ... still lets an org-shared member remove every prompt in the project (pre-existing, UN-3318). Tracked separately." The code closes it —"sync_prompts"is now in theIsOwner()list (prompt_studio_core_v2/views.py:166), pinned bytest_sync_prompts_resolves_the_owner_permission. sync_promptshas no OSS or cloud frontend consumer, so its callers are API/SDK clients holding user tokens: a shared-project caller that works today gets a 403 after merge, with nothing in the description a release manager could turn into a changelog entry.- Three further behaviour changes are absent from the description entirely: the
get_querysetrewrite (previously-visible prompts now 404), thetool_idreparent 403, and themake_profile_default404/400 changes. A reviewer working from the stated scope reviews none of them. - Fix: rewrite the body to match the diff; delete the UN-3318 paragraph; call out the
sync_prompts403 as a breaking change for token-authenticated API clients. - Confidence: High.
Open questions
- Do you agree the widening story should be reframed per the section above? It answers your own reviewer note.
- Is
create_prompt(H1) in scope here, or a follow-up ticket? If follow-up, it needs a ticket before merge — it is a bigger hole than the one being closed and it undercuts the reparent guard you built. - Was the
sync_prompts→IsOwnerchange intended to close UN-3318? The code does; the description says it does not. Should UN-3318 be closed? - Is a 403 acceptable for token-authenticated API clients calling
sync_promptson a shared project? Needs a changelog line. - Possible test flakiness: on 2 of ~25 runs,
test_profile_from_another_tool_is_not_promotableand the twoTestPromptListIsScopedToReachableToolsassertions failed with "mock called more times than asserted"; not reproducible after clearing__pycache__and ~20 further runs. Nopytest-randomlyinstalled, so ordering is deterministic. Low confidence it is real — worth a few dozen CI runs under theunit-backendxdist group.
Assumption this review rests on
Intra-org project privacy is a security boundary. The PR presupposes it — that is what the delete carve-out protects. H1 is rated on that basis. If the team's position is that any org member may touch any project, H1 drops to Low and the update() reparent guard is largely unnecessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 13 — Testing] — These reparent tests are tautological on the one property update() exists to enforce (mutant survived)
The production docstring (views.py:69-72) makes the security-critical claim that the gate must run against the existing parent. Because this test patches the permission class itself (patch(f"{self.MODULE}.IsPromptParentToolOwner") as gate, L470) and hard-codes its return (gate.return_value.has_object_permission.return_value = owner_allows, L475), the object passed to has_object_permission is never asserted.
A refactor gating the destination instead — reopening the reparent-then-delete bypass this PR closes — passes all five tests.
Mutation-verified: replacing has_object_permission(request, self, instance) with has_object_permission(request, self, object()) at views.py:88-90 → 27 passed.
Fix: patch the collaborators, not the class, as TestPromptDeletionIsOwnerOnly already does at L122. Or minimally assert gate.return_value.has_object_permission.call_args.args[2] is instance.
Confidence: High.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 13 — Testing] — reorder_prompts cross-org scoping has zero coverage (mutant survived)
The comment here claims "A cross-org or invisible id 404s here rather than reaching the permission check." All three reorder tests (test_prompt_permission_guards.py:319, 344, 379) patch get_object_or_404 with a fixed return and never inspect its first argument, so the scoping queryset is never asserted.
Mutation-verified: replacing the scoped queryset with ToolStudioPrompt.objects.all() → 27 passed.
Fix: capture the lookup mock; assert custom_tool.objects.for_user.assert_called_once_with(SHARED_MEMBER) and assert on lookup.call_args.args[0], mirroring TestMakeProfileDefaultIsScopedToItsTool at L241/248.
Confidence: High.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 13, 4] — No DB-backed test that a different-org member cannot reach a shared_to_org=True tool
This is the highest-consequence property of the whole change, and "No database is touched" is why it cannot be tested here.
permission.py:62-63 returns True on tool.shared_to_org before any other check and with no org predicate. The only thing keeping a foreign-org user out is CustomToolModelManager.get_queryset inheriting DefaultOrganizationManagerMixin's UserContext.get_organization() filter (backend/utils/models/organization_mixin.py:27-30). A SimpleNamespace tool (_tool(), L65-66, no organization attribute) and a patched CustomTool (L320/347/380/400) cannot exercise that.
If the org filter ever regresses, every test in this file still passes while shared_to_org=True becomes a cross-tenant read+edit grant. for_user also returns self.all() for org admins and service accounts (prompt_studio_core_v2/models.py:33-37) — a second uncovered path.
Fix — precedented and cheap. backend/workflow_manager/execution/tests/test_shared_execution_access.py:179 already has test_org_wide_share_does_not_cross_organizations on GroupSharingTestBase (backend/tenant_account_v2/tests.py:69). Add a sibling with an Org B tool shared_to_org=True plus a prompt; assert an Org A user gets an empty get_queryset() and a 404 from the reorder lookup. conftest.py:27-45 auto-marks it integration — no manifest edit needed.
Confidence: High.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 16 — Doc accuracy] — Three comments cite CustomToolViewSet, a class that does not exist
Also at permission.py:84 and prompt_studio_v2/views.py:44.
All three carry the entire justification for this PR's central decision — "edits honour sharing, deletes do not, because the parent tool viewset does the same." A maintainer auditing that premise greps CustomToolViewSet, finds only these three comments referencing each other, and cannot verify it. One who concludes the class was deleted may decide the parity argument no longer holds and "re-align" the gate.
grep -rn "CustomToolViewSet" backend --include="*.py" returns exactly those three comment lines. The real class is PromptStudioCoreView (prompt_studio_core_v2/views.py:131) — the class whose get_permissions this PR edits at :157-170.
Fix: replace all three with PromptStudioCoreView. The substantive claims are correct once the name is fixed.
Confidence: High.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 16 — Doc accuracy] — This docstring claims parity with IsParentToolOwner and gives a false reason for its orphan behaviour
The docstring says it "Mirrors permissions.permission.IsParentToolOwner ... but reads the parent through ... tool_id rather than prompt_studio_tool", framing the FK name as the sole divergence. It is not — the two differ in authorization outcome for orphan rows:
IsParentToolOwner(backend/permissions/permission.py:163-164) doesowner_resource = obj.prompt_studio_tool or objand documents the self-fallback as deliberate legacy behaviour.- This class does
if tool is not None and _is_resource_owner(...), then drops to org-admin.
It then justifies itself with "it has no owner to inherit from", which is false: ToolStudioPrompt has created_by (prompt_studio_v2/models.py:121).
Why it matters concretely: commit 7d4f2813d explicitly contemplates extracting a base class over these clones, and H8 independently recommends exactly that. A maintainer doing that consolidation on the strength of "mirrors" silently flips orphan-prompt authorization.
Fix: state the divergence instead of denying it; drop "it has no owner to inherit from".
Confidence: High.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 2 — Architectural fit] — Fifth near-clone parent-owner permission class, justified by an anti-drift argument the existing four already falsify
The docstring argues the clone exists so a shared class won't "drift apart". But the four pre-existing clones have already drifted, on three separate axes — so a future security fix lands in only some of them:
- null-parent:
IsParentToolOwner/IsRegistryToolOwnerfall back to the object's own ownership (obj.prompt_studio_tool or obj,obj.custom_tool or obj);IsPromptParentToolOwnerdenies (org-admin only);IsParentWorkflowOwnerdoes not guard null at all. - admin check:
IsParentWorkflowOwner/IsParentToolOwner/IsParentDeploymentOwneruse the request-caching_is_organization_admin(request); both classes inprompt_studio/permission.py— including this new one — callOrganizationMemberService.is_user_organization_admin(request.user)directly, bypassing the cache. - service account: the shared classes use
_is_service_account(request); this one inlinesgetattr(request.user, "is_service_account", False).
The count, for the record: permissions/permission.py:138 (IsParentWorkflowOwner), :151 (IsParentToolOwner), :171 (IsParentDeploymentOwner), prompt_studio/permission.py:102 (IsRegistryToolOwner), and this one. Plus IsOwner:104, IsOwnerOrSharedUser:225, IsOwnerOrSharedUserOrSharedToOrg:239 sharing the same body shape. The only stated blocker to sharing is an attribute name.
Fix: parameterise — class IsParentResourceOwner(BasePermission): parent_attr: str; fallback_to_self: bool = True, then a 3-line subclass. Smaller surface than the fifth copy. If the clone stays, at minimum switch :94 and :99 to _is_service_account(request) / _is_organization_admin(request).
Confidence: High.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 2 — Architectural fit] — This get_permissions routing fails open, and prompt authorization is now spread across four mechanisms
The mapping is destroy → owner-only, everything else → the share-permissive PromptAcesssToUser. Any action added later — a bulk-delete, an "unlink" collection POST — is silently authorized with no code change and no test failure.
Compounding it: neither class defines has_permission, so BasePermission.has_permission returns True and any detail=False action gets no check unless the author remembers an explicit check_object_permissions. Not hypothetical — that is exactly the reorder_prompts hole this PR fixes, and exactly H1. There is no framework backstop either: DEFAULT_PERMISSION_CLASSES is [] (backend/backend/settings/base.py:640, commented # TODO: Update once auth is figured).
The mapping is exhaustive over the currently routed set (urls.py:6-25), so there is no live bug — the finding is the fail-open direction. PromptStudioCoreView.get_permissions (prompt_studio_core_v2/views.py:157-170) has the same shape and consequence: import_project (detail=False) resolves to IsOwnerOrSharedUserOrSharedToOrg, never consulted.
Authorization for one resource now lives in four places: this dispatch, get_queryset() scoping (views.py:110-112), an inline class instantiation in update() (views.py:88), and an explicit check_object_permissions in reorder_prompts (views.py:157).
Fix: invert the default — an explicit per-action dict where an unmapped action raises rather than passes; or default to the restrictive class. Separately, give both classes an explicit has_permission returning request.user.is_authenticated.
Confidence: High.
Uh oh!
There was an error while loading. Please reload this page.
UN-3315 — prompts stayed read-only in org-shared Prompt Studio projects
"Share with everyone" sets
shared_to_orgon the parentCustomTool.IsOwnerOrSharedUserOrSharedToOrgalready honoured that flag, so the project was visible to the org — butPromptAcesssToUserdid not check it, so editing a prompt inside that project stayed blocked for everyone except the owner.One file, +9/−2: adds the
shared_to_orgcheck alongside the existing owner / direct-viewer / group-share / org-admin paths.Reviewer note — this widens access
Please sanity-check the intent: after this change, any org member can edit prompts in a project shared with the whole org. That is what "share with everyone" reads as, and it matches the visibility rule already in place, but it is a genuine broadening rather than a pure bug fix — worth a second opinion before merge.
The check reads
tool.shared_to_orgdirectly, matching the sibling gate atpermissions/permission.py:248. (An earlier revision usedgetattr(tool, "shared_to_org", False)and this section claimed it "degrades safely if the attribute is absent" — that was inaccurate:shared_to_orgis a non-null model field, so the default could never apply.)Update: deletion is no longer included in the widened access.
get_permissionsonToolStudioPromptViewnow routesdestroytoIsPromptParentToolOwner, so an org-shared member can view and edit prompts but not delete them, matching the parentCustomTool.destroybeingIsOwner-gated. Reads and edits are unchanged by that split.Known gap, not closed here: the bulk
sync_promptsroute onPromptStudioCoreViewstill lets an org-shared member remove every prompt in the project (pre-existing, UN-3318). Tracked separately.🤖 Generated with Claude Code
https://claude.ai/code/session_0197gRFp4QFxu2VzxhTEozGn