Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
79c985b
UN-3315 [FIX] Honour shared_to_org for Prompt Studio prompt edits
hari-kuriakose Aug 28, 2026
943b3a0
UN-3315 [FIX] Restrict prompt deletion to the parent tool's owner
hari-kuriakose Aug 30, 2026
4d8a381
UN-3315 [DOC] Correct three claims in the prompt permission gate
hari-kuriakose Aug 30, 2026
0a96979
UN-3315 [DOC] Correct the ProfileManagerView counterexample
hari-kuriakose Aug 30, 2026
75fd2ac
UN-3315 [FIX] Close the sync_prompts bulk-delete path
hari-kuriakose Aug 30, 2026
5bc4ee9
UN-3315 [REVERT] Drop the sync_prompts empty-payload guard
hari-kuriakose Aug 30, 2026
c35fbe7
UN-3315 [FIX] Gate reorder_prompts, which no permission class reached
hari-kuriakose Aug 30, 2026
70ff027
UN-3315 [FIX] Gate ProfileManager creation on parent-tool ownership
hari-kuriakose Aug 30, 2026
7e6b1cd
UN-3315 [DOC] Correct three claims about the profile and API-key surf…
hari-kuriakose Aug 30, 2026
6c7c186
UN-3315 [TEST] Pin the prompt authorization split
hari-kuriakose Aug 30, 2026
54f5fa3
UN-3315 [REVERT] Drop IsParentToolOwner.has_permission, which was unr…
hari-kuriakose Aug 30, 2026
7d4f281
UN-3315 [CLEANUP] Trim duplicated prose and save two queries per check
hari-kuriakose Aug 30, 2026
c3a0316
UN-3315 [FIX] Scope make_profile_default's lookup to its own tool
hari-kuriakose Aug 30, 2026
615b16e
UN-3315 [TEST] Pin make_profile_default's tool scoping
hari-kuriakose Aug 30, 2026
636cedf
UN-3315 [FIX] Gate a prompt reparent on the parent it leaves
hari-kuriakose Aug 31, 2026
4fdc528
UN-3315 [FIX] Scope prompt queries to the user's reachable tools
hari-kuriakose Aug 31, 2026
6d0a5bb
UN-3315 [FIX] Stop prompt/reorder.json returning a 500
hari-kuriakose Aug 31, 2026
4274af6
UN-3315 [TEST] Pin the reparent gate, list scoping and the suffix route
hari-kuriakose Aug 31, 2026
ca40729
Merge branch 'main' into un-sprint4-B-permissions
hari-kuriakose Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions backend/prompt_studio/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,55 @@


class PromptAcesssToUser(permissions.BasePermission):
"""Is the crud to Prompt/Notes allowed to user.
"""Read and edit access to a Prompt/Note, inherited from the parent tool.

A user qualifies when they own the parent ``CustomTool``, are a direct
viewer (VIEWER membership, UN-2202), reach the project via group sharing
(``ResourceGroupShare`` on the parent tool), or are an org admin
(org-wide admin override, UN-3479).
(``ResourceGroupShare`` on the parent tool), reach it because the parent
tool is shared with the whole org (``shared_to_org``, UN-3315), or are an
org admin (org-wide admin override, UN-3479).

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
``update``/``partial_update`` on the tool itself through
``IsOwnerOrSharedUserOrSharedToOrg``. ``ProfileManagerView`` looks like a
counterexample -- it routes ``update``/``partial_update``/``destroy``
through ``IsParentToolOwner`` -- but it is not: profiles are *created*
through ``PromptStudioCoreView.create_profile_manager``, which admits
org-shared users, as does ``make_profile_default``. That surface is
share-permissive and unaddressed here.

This class does not confer deletion; ``destroy`` is gated by
:class:`IsPromptParentToolOwner`, and the bulk ``sync_prompts`` route on
``PromptStudioCoreView`` is likewise ``IsOwner``-gated.

One deletion path remains open to a non-owner, known and accepted
(UN-3315): a ``read_write`` platform API key reaches ``sync_prompts``.
Service accounts short-circuit ahead of every check here, and being a POST
that route is not covered by the DELETE tier that guards per-prompt
``destroy``.

Separately, and not a hole: ``sync_prompts`` with an empty ``prompts``
list clears a project's prompts by design -- supported behaviour, asserted
by ``test_sync_prompts_clear_bumps_tool_modified_at``. Do not "fix" it with
a payload guard; that breaks a published contract and its own test. The
owner gate, not payload validation, is what stands between a share and
that wipe.
"""

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
# UN-3315: "Share with everyone" sets shared_to_org on the parent tool.
# Checked first among the grant paths because it is a free attribute
# read, while every branch below it runs a query -- and it is the path
# UN-3315 exists to serve. Order is not otherwise observable: these are
# side-effect-free predicates OR'd together.
if tool.shared_to_org:
return True
if _is_resource_owner(request.user, tool):
return True
if _is_resource_viewer(request.user, tool):
Expand All @@ -33,6 +70,35 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo
return OrganizationMemberService.is_user_organization_admin(request.user)


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)


class IsRegistryToolOwner(permissions.BasePermission):
"""Is unpublishing an exported tool allowed to user.

Expand Down
27 changes: 25 additions & 2 deletions backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.db import IntegrityError
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from file_management.constants import FileInformationKey as FileKey
from file_management.exceptions import FileNotFound
Expand All @@ -24,6 +25,7 @@
from plugins import get_plugin
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
Expand Down Expand Up @@ -153,7 +155,16 @@ def get_serializer_class(self):
return CustomToolSerializer

def get_permissions(self) -> list[Any]:
if self.action in ["destroy", "add_co_owner", "remove_co_owner"]:
# sync_prompts is a rip-and-replace: it deletes every prompt in the
# project before importing. That is owner-level destruction, so it
# belongs here rather than falling through to the share-aware class
# (UN-3315: a share grants view + edit, not delete).
if self.action in [
"destroy",
"add_co_owner",
"remove_co_owner",
"sync_prompts",
]:
return [IsOwner()]

return [IsOwnerOrSharedUserOrSharedToOrg()]
Expand Down Expand Up @@ -389,11 +400,23 @@ def make_profile_default(self, request: HttpRequest, pk: Any = None) -> Response
self.get_object()
) # Assuming you have a get_object method in your viewset

default_profile = request.data.get("default_profile")
if not default_profile:
raise ValidationError({"default_profile": "This field is required."})

# Resolve before clearing, and scope to this tool. Unscoped, a profile
# belonging to another tool could be promoted here -- and because the
# clear above had already run, the tool was left with no default of its
# own and a foreign profile marked default. A mismatch now 404s with
# nothing modified.
profile_manager = get_object_or_404(
ProfileManager, pk=default_profile, prompt_studio_tool=prompt_tool
)

ProfileManager.objects.filter(prompt_studio_tool=prompt_tool).update(
is_default=False
)

profile_manager = ProfileManager.objects.get(pk=request.data["default_profile"])
profile_manager.is_default = True
profile_manager.save()

Expand Down
Empty file.
Loading
Loading