Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.db import models
from django.db.models import QuerySet
from tenant_account_v2.models import OrganizationMember
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.exceptions import InvalidEncryptionKey
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
Expand Down Expand Up @@ -37,6 +38,9 @@ def for_user(self, user: User) -> QuerySet[Any]:
if getattr(user, "is_service_account", False):
return self.get_queryset().filter(is_friction_less=False)

if OrganizationMemberService.is_user_organization_admin(user):
return self.get_queryset()
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return (
self.get_queryset()
.filter(
Expand Down
7 changes: 5 additions & 2 deletions backend/api_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

from account_v2.models import User
from django.db import models
from django.db.models import Q
from django.db.models.signals import post_delete
from django.dispatch import receiver
from pipeline_v2.models import Pipeline
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
Expand All @@ -30,12 +32,13 @@ def for_user(self, user):
- API deployments created by the user
- API deployments shared with the user
- API deployments shared with the entire organization
- Service accounts see all org resources
- Service accounts and org admins see all org resources
"""
if getattr(user, "is_service_account", False):
return self.all()

from django.db.models import Q
if OrganizationMemberService.is_user_organization_admin(user):
return self.all()

return self.filter(
Q(created_by=user) # Owned by user
Expand Down
4 changes: 4 additions & 0 deletions backend/connector_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from connector_processor.connector_processor import ConnectorProcessor
from connector_processor.constants import ConnectorKeys
from django.db import models
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.fields import EncryptedBinaryField
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
Expand All @@ -30,6 +31,9 @@ def for_user(self, user: User) -> models.QuerySet:
if getattr(user, "is_service_account", False):
return self.all()

if OrganizationMemberService.is_user_organization_admin(user):
return self.all()

return (
self.get_queryset()
.filter(
Expand Down
84 changes: 57 additions & 27 deletions backend/permissions/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
from rest_framework import permissions
from rest_framework.request import Request
from rest_framework.views import APIView
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.user_context import UserContext

_REQUEST_ADMIN_CACHE_ATTR = "_cached_is_organization_admin"


def _is_service_account(request: Request) -> bool:
"""Allow service accounts through for all non-DELETE methods.
Expand All @@ -21,13 +24,37 @@ def _is_service_account(request: Request) -> bool:
return getattr(request.user, "is_service_account", False)


def _is_organization_admin(request: Request) -> bool:
"""Return True if the requesting user has the org-admin role.

Result is cached per-request to keep cost at one membership lookup per
incoming HTTP call regardless of how many permission classes ask.
"""
cached = getattr(request, _REQUEST_ADMIN_CACHE_ATTR, None)
if cached is not None:
return cached
is_admin = OrganizationMemberService.is_user_organization_admin(request.user)
setattr(request, _REQUEST_ADMIN_CACHE_ATTR, is_admin)
return is_admin


class IsOwner(permissions.BasePermission):
"""Custom permission to only allow owners of an object."""
"""Allow owners and org admins.

Org admins can manage every resource in their organization regardless of
``created_by``. This matches the "admin role manages everything" model
expected by org-to-org migration (resources land owned by a service
account) and by typical admin UX.
"""

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if _is_service_account(request):
return True
return True if obj.created_by == request.user else False
if obj.created_by == request.user:
return True
if _is_organization_admin(request):
return True
return False


class IsOrganizationMember(permissions.BasePermission):
Expand All @@ -39,43 +66,44 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo


class IsOwnerOrSharedUser(permissions.BasePermission):
"""Custom permission to only allow owners and shared users of an object."""
"""Allow owners, shared users, and org admins."""

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if _is_service_account(request):
return True
return (
True
if (
obj.created_by == request.user
or obj.shared_users.filter(pk=request.user.pk).exists()
)
else False
)
if obj.created_by == request.user:
return True
if obj.shared_users.filter(pk=request.user.pk).exists():
return True
if _is_organization_admin(request):
return True
return False


class IsOwnerOrSharedUserOrSharedToOrg(permissions.BasePermission):
"""Custom permission to only allow owners and shared users of an object or
if it is shared to org.
"""
"""Allow owners, shared users, org-shared objects, and org admins."""

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if _is_service_account(request):
return True
return (
True
if (
obj.created_by == request.user
or obj.shared_users.filter(pk=request.user.pk).exists()
or obj.shared_to_org
)
else False
)
if obj.created_by == request.user:
return True
if obj.shared_users.filter(pk=request.user.pk).exists():
return True
if obj.shared_to_org:
return True
if _is_organization_admin(request):
return True
return False


class IsFrictionLessAdapter(permissions.BasePermission):
"""Hack for friction-less onboarding not allowing user to view or updating
friction less adapter.

Friction-less adapters wrap platform-owned credentials, so they remain
blocked for everyone including org admins -- exposing them would leak
the platform's keys.
"""

def has_object_permission(
Expand All @@ -85,8 +113,9 @@ def has_object_permission(
return False
if _is_service_account(request):
return True

return True if obj.created_by == request.user else False
if obj.created_by == request.user:
return True
return _is_organization_admin(request)


class IsFrictionLessAdapterDelete(permissions.BasePermission):
Expand All @@ -99,5 +128,6 @@ def has_object_permission(
) -> bool:
if obj.is_friction_less:
return True

return True if obj.created_by == request.user else False
if obj.created_by == request.user:
return True
return _is_organization_admin(request)
6 changes: 5 additions & 1 deletion backend/pipeline_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.conf import settings
from django.db import models
from django.db.models import Q
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
Expand All @@ -24,11 +25,14 @@ def for_user(self, user):
- Pipelines created by the user
- Pipelines shared with the user
- Pipelines shared with the entire organization
- Service accounts see all org resources
- Service accounts and org admins see all org resources
"""
if getattr(user, "is_service_account", False):
return self.all()

if OrganizationMemberService.is_user_organization_admin(user):
return self.all()

return self.filter(
Q(created_by=user) # Owned by user
| Q(shared_users=user) # Shared with user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 4.2.1 on 2026-05-24 08:36

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("prompt_profile_manager_v2", "0004_merge_20250805_1025"),
]

operations = [
migrations.AddField(
model_name="profilemanager",
name="shared_to_org",
field=models.BooleanField(
db_comment="Whether this profile is shared with the entire organization",
default=False,
),
),
migrations.AddField(
model_name="profilemanager",
name="shared_users",
field=models.ManyToManyField(
blank=True,
related_name="shared_profile_managers",
to=settings.AUTH_USER_MODEL,
),
),
]
37 changes: 36 additions & 1 deletion backend/prompt_studio/prompt_profile_manager_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,37 @@
from account_v2.models import User
from adapter_processor_v2.models import AdapterInstance
from django.db import models
from utils.models.base_model import BaseModel
from django.db.models import Q
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.models.base_model import BaseModel, BaseModelManager
from utils.user_context import UserContext

from prompt_studio.prompt_studio_core_v2.exceptions import DefaultProfileError
from prompt_studio.prompt_studio_core_v2.models import CustomTool


class ProfileManagerModelManager(BaseModelManager):
def for_user(self, user):
"""Mirror the visibility model used by Workflow/Pipeline/etc.

Org-scoped via the parent CustomTool — ProfileManager has no
direct ``organization`` FK, so the ``shared_to_org=True`` branch
would otherwise leak rows across tenants for guessed UUIDs.
"""
org_scope = Q(prompt_studio_tool__organization=UserContext.get_organization())
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.

if getattr(user, "is_service_account", False):
return self.filter(org_scope)

if OrganizationMemberService.is_user_organization_admin(user):
return self.filter(org_scope)

return self.filter(
org_scope
& (Q(created_by=user) | Q(shared_users=user) | Q(shared_to_org=True))
).distinct()
Comment thread
greptile-apps[bot] marked this conversation as resolved.


class ProfileManager(BaseModel):
"""Model to store the LLM Triad management details for Prompt."""

Expand Down Expand Up @@ -101,6 +126,16 @@ class RetrievalStrategy(models.TextChoices):
db_comment="DEPRECATED: Default LLM Profile used for summarizing. Use CustomTool.summarize_llm_adapter instead.",
)

shared_users = models.ManyToManyField(
User, related_name="shared_profile_managers", blank=True
)
shared_to_org = models.BooleanField(
default=False,
db_comment="Whether this profile is shared with the entire organization",
)

objects = ProfileManagerModelManager()

class Meta:
verbose_name = "Profile Manager"
verbose_name_plural = "Profile Managers"
Expand Down
14 changes: 9 additions & 5 deletions backend/prompt_studio/prompt_profile_manager_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.db import IntegrityError
from django.db.models import QuerySet
from django.http import HttpRequest
from permissions.permission import IsOwner
from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg
from rest_framework import status, viewsets
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
Expand All @@ -23,18 +23,22 @@ class ProfileManagerView(viewsets.ModelViewSet):
"""Viewset to handle all Custom tool related operations."""

versioning_class = URLPathVersioning
permission_classes = [IsOwner]
serializer_class = ProfileManagerSerializer

def get_permissions(self) -> list[Any]:
# Mutations require ownership; reads honor sharing.
if self.action in ("create", "destroy", "partial_update", "update"):
return [IsOwner()]
return [IsOwnerOrSharedUserOrSharedToOrg()]
Comment thread
greptile-apps[bot] marked this conversation as resolved.

def get_queryset(self) -> QuerySet | None:
queryset = ProfileManager.objects.for_user(self.request.user)
filter_args = FilterHelper.build_filter_args(
self.request,
ProfileManagerKeys.CREATED_BY,
)
if filter_args:
queryset = ProfileManager.objects.filter(**filter_args)
else:
queryset = ProfileManager.objects.all()
queryset = queryset.filter(**filter_args)
return queryset

def create(
Expand Down
4 changes: 4 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from adapter_processor_v2.models import AdapterInstance
from django.db import models
from django.db.models import QuerySet
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.file_storage.constants import FileStorageKeys
from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper
from utils.models.base_model import BaseModel, BaseModelManager
Expand All @@ -26,6 +27,9 @@ def for_user(self, user: User) -> QuerySet[Any]:
if getattr(user, "is_service_account", False):
return self.all()

if OrganizationMemberService.is_user_organization_admin(user):
return self.all()

return (
self.get_queryset()
.filter(
Expand Down
Loading