Skip to content

Navigation Menu

Sign in
Sign up

UN-3315 [FIX] Honour shared_to_org for Prompt Studio prompt edits - #2259

Open
hari-kuriakose wants to merge 19 commits into
main from
un-sprint4-B-permissions
Open

UN-3315 [FIX] Honour shared_to_org for Prompt Studio prompt edits #2259
hari-kuriakose wants to merge 19 commits into
main from
un-sprint4-B-permissions

Conversation

@hari-kuriakose

@hari-kuriakose hari-kuriakose commented Aug 29, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

UN-3315 — prompts stayed read-only in org-shared Prompt Studio projects

"Share with everyone" sets shared_to_org on the parent CustomTool. IsOwnerOrSharedUserOrSharedToOrg already honoured that flag, so the project was visible to the org — but PromptAcesssToUser did 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_org check 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_org directly, matching the sibling gate at permissions/permission.py:248. (An earlier revision used getattr(tool, "shared_to_org", False) and this section claimed it "degrades safely if the attribute is absent" — that was inaccurate: shared_to_org is a non-null model field, so the default could never apply.)

Update: deletion is no longer included in the widened access. get_permissions on ToolStudioPromptView now routes destroy to IsPromptParentToolOwner, so an org-shared member can view and edit prompts but not delete them, matching the parent CustomTool.destroy being IsOwner-gated. Reads and edits are unchanged by that split.

Known gap, not closed here: the bulk sync_prompts route on PromptStudioCoreView still 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

greptile-apps[bot] reacted with thumbs up emoji
hari-kuriakose and others added 18 commits August 28, 2026 16:11
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 

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown

hari-kuriakose marked this pull request as ready for review August 31, 2026 20:52

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR aligns Prompt Studio prompt editing with organization-wide project sharing while retaining owner-level controls for destructive operations. It also tightens object scoping and validation around prompt reordering and default-profile selection.

  • Grants prompt read and edit access when the parent tool is shared with the organization.
  • Restricts individual prompt deletion and bulk prompt synchronization to parent-tool owners, while preserving service-account and organization-admin behavior.
  • Scopes prompt lists and reorder targets to tools reachable by the requester and protects reparenting from shared projects.
  • Validates and scopes default-profile promotion before clearing the existing default.
  • Adds focused regression tests for permission routing, object scoping, reordering, reparenting, and profile selection.

Confidence Score: 5/5

The PR appears safe to merge, with the widened edit access consistently bounded by organization-scoped object resolution and separate owner-level destructive-operation gates.

The changed paths preserve organization boundaries, authorize the same objects they mutate, retain legitimate owner, co-owner, administrator, and service-account behavior, and add regression coverage for the principal authorization transitions.

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into un-sprint4-B-pe..." | Re-trigger Greptile

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 15.1
e2e-coowners e2e 1 0 0 0 1.0
e2e-etl e2e 1 0 0 0 8.0
e2e-login e2e 2 0 0 0 0.8
e2e-prompt-studio e2e 1 0 0 0 4.5
e2e-smoke e2e 2 0 0 0 1.1
e2e-workflow e2e 1 0 0 0 22.2
integration-backend integration 310 0 0 26 39.4
integration-connectors integration 1 0 0 7 6.4
integration-workers integration 157 0 0 1 50.4
unit-backend unit 1185 0 0 1 30.5
unit-connectors unit 63 0 0 0 8.5
unit-core unit 33 0 0 0 0.9
unit-platform-service unit 15 0 0 0 1.9
unit-rig unit 117 0 0 0 4.2
unit-runner unit 5 0 0 0 2.9
unit-sdk1 unit 563 0 0 0 26.5
unit-workers unit 1397 0 0 1 121.5
TOTAL 3857 0 0 36 345.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

@chandrasekharan-zipstack chandrasekharan-zipstack 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.

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.

  1. shared_to_org already conferred write on the parent object — PromptStudioCoreView.get_permissions (prompt_studio_core_v2/views.py:157-170) routes update/partial_update through IsOwnerOrSharedUserOrSharedToOrg. An org-shared member could already edit the project itself. Prompts being read-only inside a project you can rename was the inconsistency.
  2. create_prompt is completely ungated today (see H1 below). "Org members can write prompts" is not new; this PR makes the coherent subset of it work.
  3. The delete carve-out is the right line, correctly drawn: destroyIsPromptParentToolOwner, sync_promptsIsOwner.

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 (route urls.py:100-104; gate views.py:157-170)
  • Failure mode: @action(detail=True) but never calls self.get_object(). IsOwnerOrSharedUserOrSharedToOrg defines only has_object_permission, so BasePermission.has_permission returns True and nothing is checked. The <uuid:pk> is bound and never used; the parent comes entirely from request.data["tool_id"], and ToolStudioPromptSerializer is fields = "__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 calls self.get_object() only when the body omits prompt_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. No perform_create override on PromptStudioCoreView. Cross-org is blocked (DRF resolves the FK via the org-filtered CustomTool._default_manager); same-org is not.
  • Fix: tool = self.get_object() at the top of create_prompt, then force serializer.validated_data["tool_id"] = tool before perform_create. Make create_profile_manager's get_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_prompts route ... 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 the IsOwner() list (prompt_studio_core_v2/views.py:166), pinned by test_sync_prompts_resolves_the_owner_permission.
  • sync_prompts has 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_queryset rewrite (previously-visible prompts now 404), the tool_id reparent 403, and the make_profile_default 404/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_prompts 403 as a breaking change for token-authenticated API clients.
  • Confidence: High.

Open questions

  1. Do you agree the widening story should be reframed per the section above? It answers your own reviewer note.
  2. 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.
  3. Was the sync_promptsIsOwner change intended to close UN-3318? The code does; the description says it does not. Should UN-3318 be closed?
  4. Is a 403 acceptable for token-authenticated API clients calling sync_prompts on a shared project? Needs a changelog line.
  5. Possible test flakiness: on 2 of ~25 runs, test_profile_from_another_tool_is_not_promotable and the two TestPromptListIsScopedToReachableTools assertions failed with "mock called more times than asserted"; not reproducible after clearing __pycache__ and ~20 further runs. No pytest-randomly installed, so ordering is deterministic. Low confidence it is real — worth a few dozen CI runs under the unit-backend xdist 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.

Comment on lines +468 to +480
with (
patch.object(ToolStudioPromptView, "get_object", return_value=instance),
patch(f"{self.MODULE}.IsPromptParentToolOwner") as gate,
patch(
"rest_framework.viewsets.ModelViewSet.update", return_value="updated"
) as parent_update,
):
gate.return_value.has_object_permission.return_value = owner_allows
try:
result = view.update(request)
except PermissionDenied:
return "denied", parent_update
return result, parent_update

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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-9027 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.

Comment on lines +144 to +149
raise ValidationError({ToolStudioPromptKeys.PROMPT_ID: "This is required."})
# ToolStudioPrompt carries no organization of its own -- it is a plain
# BaseModel -- so scope through the parent tool, whose for_user()
# queryset is org-bound. A cross-org or invisible id 404s here rather
# than reaching the permission check. select_related because the
# permission class immediately dereferences the tool_id FK, and the

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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.

Comment on lines +31 to +32
No database is touched. ``get_permissions()`` is pure, and the collaborators
that would hit the ORM are patched.

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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.

Deliberately broader than the workflow rule stated in
``permissions.permission.is_workflow_mutator`` ("shared access grants read
only, never mutate"): a Prompt Studio share confers *edit* rights on the
project's prompts, matching ``CustomToolViewSet``, which already routes

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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.

Comment on lines +76 to +90
Mirrors ``permissions.permission.IsParentToolOwner``, which does the same
for ``ProfileManager``, but reads the parent through ``ToolStudioPrompt``'s
own FK name (``tool_id``) rather than ``prompt_studio_tool``. Kept as a
separate class rather than teaching the shared one to juggle both attribute
names: a shared authorization class that accumulates per-caller special
cases is how these gates drift apart.

Exists because the parent ``CustomTool``'s own ``destroy`` is owner-only
(``IsOwner`` in ``CustomToolViewSet.get_permissions``). Without this,
UN-3315's org-wide share would let any org member delete every prompt
inside a project they cannot themselves delete.

``tool_id`` is nullable (``SET_NULL``), so an orphaned prompt whose parent
tool was deleted falls back to the org-admin check -- it has no owner to
inherit from.

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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) does owner_resource = obj.prompt_studio_tool or obj and 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.

Comment on lines +73 to +99
class IsPromptParentToolOwner(permissions.BasePermission):
"""Deletion gate for Prompt Studio prompts/notes.

Mirrors ``permissions.permission.IsParentToolOwner``, which does the same
for ``ProfileManager``, but reads the parent through ``ToolStudioPrompt``'s
own FK name (``tool_id``) rather than ``prompt_studio_tool``. Kept as a
separate class rather than teaching the shared one to juggle both attribute
names: a shared authorization class that accumulates per-caller special
cases is how these gates drift apart.

Exists because the parent ``CustomTool``'s own ``destroy`` is owner-only
(``IsOwner`` in ``CustomToolViewSet.get_permissions``). Without this,
UN-3315's org-wide share would let any org member delete every prompt
inside a project they cannot themselves delete.

``tool_id`` is nullable (``SET_NULL``), so an orphaned prompt whose parent
tool was deleted falls back to the org-admin check -- it has no owner to
inherit from.
"""

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if getattr(request.user, "is_service_account", False):
return True
tool = obj.tool_id
if tool is not None and _is_resource_owner(request.user, tool):
return True
return OrganizationMemberService.is_user_organization_admin(request.user)

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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 / IsRegistryToolOwner fall back to the object's own ownership (obj.prompt_studio_tool or obj, obj.custom_tool or obj); IsPromptParentToolOwner denies (org-admin only); IsParentWorkflowOwner does not guard null at all.
  • admin check: IsParentWorkflowOwner / IsParentToolOwner / IsParentDeploymentOwner use the request-caching _is_organization_admin(request); both classes in prompt_studio/permission.py — including this new one — call OrganizationMemberService.is_user_organization_admin(request.user) directly, bypassing the cache.
  • service account: the shared classes use _is_service_account(request); this one inlines getattr(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.

Comment on lines +40 to +49
def get_permissions(self) -> list[Any]:
# Reads and edits honour project sharing (UN-3315), enforced on two
# levels: get_queryset scopes every action to the user's reachable
# tools, and these classes gate the object. Deleting a prompt requires
# ownership of the parent tool, matching CustomToolViewSet, whose own
# `destroy` is IsOwner-gated. The bulk `sync_prompts` route there is
# IsOwner-gated too. API-key gap: see PromptAcesssToUser.
if self.action == "destroy":
return [IsPromptParentToolOwner()]
return [PromptAcesssToUser()]

@chandrasekharan-zipstack chandrasekharan-zipstack Sep 9, 2026

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.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@chandrasekharan-zipstack chandrasekharan-zipstack chandrasekharan-zipstack requested changes
@muhammad-ali-e muhammad-ali-e Awaiting requested review from muhammad-ali-e

Requested changes must be addressed to merge this pull request.

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

AltStyle によって変換されたページ (->オリジナル) /