Repository: Flagsmith/flagsmith
Pull request #5408: feat(5308): Add endpoints to support permission debugging
Author: login: gagantrivedi
Labels: name: feature
description: New feature or request
name: api
description: Issue related to the REST API
Created: 2025-05-01T07:39:16Z | Merged: 2025-05-14T13:25:33Z

Thanks for submitting a PR! Please check the boxes below:

- [ ] I have added information to `docs/` if required so people know about the feature!
- [x] I have filled in the "Changes" section below?
- [x] I have filled in the "How did you test this code" section below?
- [x] I have used a [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) title for this Pull Request

## Changes

Implement Endpoints defined [here](https://github.com/Flagsmith/flagsmith/issues/5308)


## How did you test this code?

<!-- If the answer is manually, please include a quick step-by-step on how to test this PR. -->

- tested manually with rbac installed to make sure tags/role stuff is working correctly
- Adds unit tests  


Diff:
diff --git a/api/environments/permissions/permissions.py b/api/environments/permissions/permissions.py
index 88d786a13943..374adfbc5113 100644
--- a/api/environments/permissions/permissions.py
+++ b/api/environments/permissions/permissions.py
@@ -53,7 +53,8 @@ def has_object_permission(self, request, view, obj):  # type: ignore[no-untyped-
             return request.user.has_environment_permission(VIEW_ENVIRONMENT, obj)
 
         return request.user.is_environment_admin(obj) or view.action in [
-            "user_permissions"
+            "user_permissions",
+            "detailed_permissions",
         ]
 
 
diff --git a/api/environments/views.py b/api/environments/views.py
index 01af46a2854f..9b035deb95e7 100644
--- a/api/environments/views.py
+++ b/api/environments/views.py
@@ -30,9 +30,11 @@
 from permissions.permissions_calculator import get_environment_permission_data
 from permissions.serializers import (
     PermissionModelSerializer,
+    UserDetailedPermissionsSerializer,
     UserObjectPermissionsSerializer,
 )
 from projects.models import Project
+from users.models import FFAdminUser
 from webhooks.mixins import TriggerSampleWebhookMixin
 from webhooks.webhooks import WebhookType
 
@@ -243,6 +245,35 @@ def user_permissions(self, request, *args, **kwargs):  # type: ignore[no-untyped
         serializer = UserObjectPermissionsSerializer(instance=permission_data)
         return Response(serializer.data)
 
+    @swagger_auto_schema(
+        responses={200: UserDetailedPermissionsSerializer},
+    )  # type: ignore[misc]
+    @action(
+        detail=True,
+        methods=["GET"],
+        url_path=r"user-detailed-permissions/(?P<user_id>\d+)",
+        url_name="user-detailed-permissions",
+    )
+    def detailed_permissions(
+        self, request: Request, api_key: str, user_id: str
+    ) -> Response:
+        environment = self.get_object()
+        user = request.user
+        user_id_int = int(user_id)
+
+        if request.user.id != user_id_int:
+            if not request.user.is_environment_admin(environment):  # type: ignore[union-attr]
+                # Only environment admin can get permissions of other users
+                raise PermissionDenied()
+
+            user = get_object_or_404(FFAdminUser, id=user_id_int)
+
+        permission_data = get_environment_permission_data(environment, user)  # type: ignore[arg-type]
+        serializer = UserDetailedPermissionsSerializer(
+            permission_data.to_detailed_permissions_data()
+        )
+        return Response(serializer.data)
+
     @swagger_auto_schema(responses={200: SDKEnvironmentDocumentModel})  # type: ignore[misc]
     @action(detail=True, methods=["GET"], url_path="document")
     def get_document(self, request, api_key: str):  # type: ignore[no-untyped-def]
diff --git a/api/organisations/permissions/permissions.py b/api/organisations/permissions/permissions.py
index 59a383a84e21..aca7d9a41501 100644
--- a/api/organisations/permissions/permissions.py
+++ b/api/organisations/permissions/permissions.py
@@ -105,7 +105,8 @@ def has_permission(self, request, view):  # type: ignore[no-untyped-def]
 
     def has_object_permission(self, request, view, obj):  # type: ignore[no-untyped-def]
         return request.user.is_organisation_admin(obj) or (
-            view.action == "my_permissions" and request.user.belongs_to(obj)
+            view.action in ["my_permissions", "detailed_permissions"]
+            and request.user.belongs_to(obj)
         )
 
 
diff --git a/api/organisations/views.py b/api/organisations/views.py
index 15ed8b75b202..105d9d19a85e 100644
--- a/api/organisations/views.py
+++ b/api/organisations/views.py
@@ -10,7 +10,7 @@
 from rest_framework import status, viewsets
 from rest_framework.authentication import BasicAuthentication
 from rest_framework.decorators import action, api_view, authentication_classes
-from rest_framework.exceptions import ValidationError
+from rest_framework.exceptions import PermissionDenied, ValidationError
 from rest_framework.generics import ListAPIView, get_object_or_404
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.request import Request
@@ -53,9 +53,11 @@
 from permissions.permissions_calculator import get_organisation_permission_data
 from permissions.serializers import (
     PermissionModelSerializer,
+    UserDetailedPermissionsSerializer,
     UserObjectPermissionsSerializer,
 )
 from projects.serializers import ProjectListSerializer
+from users.models import FFAdminUser
 from users.serializers import UserIdSerializer
 from webhooks.mixins import TriggerSampleWebhookMixin
 from webhooks.webhooks import WebhookType
@@ -275,6 +277,33 @@ def my_permissions(self, request, pk):  # type: ignore[no-untyped-def]
 
         return Response(serializer.data)
 
+    @swagger_auto_schema(
+        responses={200: UserDetailedPermissionsSerializer},
+    )  # type: ignore[misc]
+    @action(
+        detail=True,
+        methods=["GET"],
+        url_path=r"user-detailed-permissions/(?P<user_id>\d+)",
+        url_name="user-detailed-permissions",
+    )
+    def detailed_permissions(self, request: Request, pk: str, user_id: str) -> Response:
+        organisation = self.get_object()
+        user = request.user
+        user_id_int = int(user_id)
+
+        if request.user.id != user_id_int:
+            if not request.user.is_organisation_admin(organisation):  # type: ignore[union-attr]
+                # Only organisation admin can get permissions of other users
+                raise PermissionDenied()
+
+            user = get_object_or_404(FFAdminUser, id=user_id_int)
+
+        permission_data = get_organisation_permission_data(organisation, user)  # type: ignore[arg-type]
+        serializer = UserDetailedPermissionsSerializer(
+            permission_data.to_detailed_permissions_data()
+        )
+        return Response(serializer.data)
+
 
 @api_view(["POST"])
 @authentication_classes([BasicAuthentication])
diff --git a/api/permissions/permissions_calculator.py b/api/permissions/permissions_calculator.py
index 46492c44d5f3..cc8a10cfcb36 100644
--- a/api/permissions/permissions_calculator.py
+++ b/api/permissions/permissions_calculator.py
@@ -13,13 +13,12 @@
     UserPermissionGroupOrganisationPermission,
 )
 from projects.models import (
+    Project,
     UserPermissionGroupProjectPermission,
     UserProjectPermission,
 )
 
-from .permission_service import is_user_project_admin
 from .rbac_wrapper import (  # type: ignore[attr-defined]
-    RolePermissionData,
     get_roles_permission_data_for_environment,
     get_roles_permission_data_for_organisation,
     get_roles_permission_data_for_project,
@@ -29,7 +28,6 @@
     from environments.models import Environment
     from users.models import FFAdminUser
 
-
 UserPermissionType = typing.Union[
     UserProjectPermission, UserEnvironmentPermission, UserOrganisationPermission
 ]
@@ -43,7 +41,7 @@
 ]
 
 
-@dataclass
+@dataclass(kw_only=True)
 class _PermissionDataBase:
     admin: bool = False
     permissions: typing.Set[str] = field(default_factory=set)
@@ -55,6 +53,20 @@ class GroupData:
     name: str
 
 
+@dataclass
+class RoleData:
+    id: int
+    name: str
+    tags: typing.Set[int]
+
+
+@dataclass
+class RolePermissionData(
+    _PermissionDataBase,
+):
+    role: RoleData
+
+
 @dataclass
 class _GroupPermissionBase:
     group: GroupData
@@ -70,6 +82,27 @@ class GroupPermissionData(_PermissionDataBase, _GroupPermissionBase):
     pass
 
 
+@dataclass
+class PermissionDerivedFromData:
+    groups: typing.List[GroupData] = field(default_factory=list)
+    roles: typing.List[RoleData] = field(default_factory=list)
+
+
+@dataclass
+class DetailedPermissionsData:
+    permission_key: str
+    is_directly_granted: bool
+    derived_from: PermissionDerivedFromData
+
+
+@dataclass
+class UserDetailedPermissionsData:
+    admin: bool
+    derived_from: PermissionDerivedFromData
+    is_directly_granted: bool
+    permissions: typing.List[DetailedPermissionsData]
+
+
 @dataclass
 class PermissionData:
     """
@@ -78,20 +111,24 @@ class PermissionData:
 
     user: UserPermissionData
     groups: typing.List[GroupPermissionData]
-    roles: typing.List[  # type: ignore[valid-type]
-        "RolePermissionData" if "RolePermissionData" in globals() else typing.Any
-    ]
+    roles: typing.List[RolePermissionData]
     is_organisation_admin: bool = False
     admin_override: bool = False
+    inherited_admin_groups: typing.List[GroupPermissionData] = field(
+        default_factory=list
+    )
+    inherited_admin_roles: typing.List[RolePermissionData] = field(default_factory=list)
 
     @property
     def admin(self) -> bool:
-        return (
+        return bool(
             self.is_organisation_admin
             or self.user.admin
             or any(group.admin for group in self.groups)
             or any(role.admin for role in self.roles)
             or self.admin_override
+            or self.inherited_admin_groups
+            or self.inherited_admin_roles
         )
 
     @property
@@ -127,14 +164,77 @@ def tag_based_permissions(self) -> list[dict]:  # type: ignore[type-arg]
             if role_permission.role.tags
         ]
 
+    def to_detailed_permissions_data(self) -> UserDetailedPermissionsData:  # noqa: C901
+        return _UserDetailedPermissionBuilder(self).build()
 
-def get_project_permission_data(project_id: int, user_id: int) -> PermissionData:
-    project_permission_svc = _ProjectPermissionService(project_id, user_id)
-    return PermissionData(
-        groups=get_groups_permission_data(project_permission_svc.group_qs),
-        user=get_user_permission_data(project_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_project(project_id, user_id),
-    )
+
+class _UserDetailedPermissionBuilder:
+    def __init__(self, permission_data: PermissionData):
+        self.permission_data = permission_data
+        self.permission_map: dict[str, DetailedPermissionsData] = {}
+        self.is_direct_admin = False
+        self.admin_derived_from = PermissionDerivedFromData(
+            groups=[gp.group for gp in permission_data.inherited_admin_groups],
+            roles=[rp.role for rp in permission_data.inherited_admin_roles],
+        )
+
+    def build(self) -> UserDetailedPermissionsData:
+        self._add_user_permissions()
+        self._add_group_permissions()
+        self._add_role_permissions()
+        self._check_is_direct_admin()
+
+        return UserDetailedPermissionsData(
+            admin=self.permission_data.admin,
+            is_directly_granted=self.is_direct_admin,
+            derived_from=self.admin_derived_from,
+            permissions=list(self.permission_map.values()),
+        )
+
+    def _add_permission(
+        self,
+        permission_key: str,
+        group: typing.Optional[GroupData] = None,
+        role: typing.Optional[RoleData] = None,
+    ) -> None:
+        if permission_key not in self.permission_map:
+            self.permission_map[permission_key] = DetailedPermissionsData(
+                permission_key=permission_key,
+                is_directly_granted=group is None and role is None,
+                derived_from=PermissionDerivedFromData(),
+            )
+        if group:
+            self.permission_map[permission_key].derived_from.groups.append(group)
+        if role:
+            self.permission_map[permission_key].derived_from.roles.append(role)
+
+    def _add_user_permissions(self) -> None:
+        for permission_key in self.permission_data.user.permissions:
+            if self.permission_data.user.admin:
+                self.is_direct_admin = True
+            self._add_permission(permission_key)
+
+    def _add_group_permissions(self) -> None:
+        for group_permission in self.permission_data.groups:
+            if group_permission.admin:
+                self.admin_derived_from.groups.append(group_permission.group)
+            for permission_key in group_permission.permissions:
+                self._add_permission(permission_key, group=group_permission.group)
+
+    def _add_role_permissions(self) -> None:
+        for role_permission in self.permission_data.roles:
+            if role_permission.admin:
+                self.admin_derived_from.roles.append(role_permission.role)
+            for permission_key in role_permission.permissions:
+                self._add_permission(permission_key, role=role_permission.role)
+
+    def _check_is_direct_admin(self) -> None:
+        if (
+            self.permission_data.is_organisation_admin
+            or self.permission_data.user.admin
+            or self.permission_data.admin_override
+        ):
+            self.is_direct_admin = True
 
 
 def get_organisation_permission_data(
@@ -145,19 +245,42 @@ def get_organisation_permission_data(
         is_organisation_admin=user.is_organisation_admin(organisation_id),
         groups=get_groups_permission_data(org_permission_svc.group_qs),
         user=get_user_permission_data(org_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_organisation(organisation_id, user.id),
+        roles=org_permission_svc.roles,
+    )
+
+
+def get_project_permission_data(
+    project: Project, user: "FFAdminUser"
+) -> PermissionData:
+    project_permission_svc = _ProjectPermissionService(project.id, user.id)
+    return PermissionData(
+        is_organisation_admin=user.is_organisation_admin(project.organisation_id),
+        groups=get_groups_permission_data(project_permission_svc.group_qs),
+        user=get_user_permission_data(project_permission_svc.user_permission),  # type: ignore[arg-type]
+        roles=project_permission_svc.roles,
     )
 
 
 def get_environment_permission_data(
     environment: "Environment", user: "FFAdminUser"
 ) -> PermissionData:
-    environment_permission_svc = _EnvironmentPermissionService(environment.id, user.id)
+    project_permission_svc = _ProjectPermissionService(environment.project_id, user.id)
+    environment_permission_svc = _EnvironmentPermissionService(
+        environment.id, user.id, project_permission_svc=project_permission_svc
+    )
+
     return PermissionData(
+        is_organisation_admin=user.is_organisation_admin(
+            environment.project.organisation_id
+        ),
         groups=get_groups_permission_data(environment_permission_svc.group_qs),
         user=get_user_permission_data(environment_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_environment(environment.id, user.id),
-        admin_override=is_user_project_admin(user, project=environment.project),
+        roles=environment_permission_svc.roles_data,
+        inherited_admin_groups=get_groups_permission_data(
+            environment_permission_svc.inherited_admin_group_qs
+        ),
+        inherited_admin_roles=environment_permission_svc.inherited_admin_roles,
+        admin_override=environment_permission_svc.inherited_user_admin,
     )
 
 
@@ -222,11 +345,46 @@ def group_qs(self) -> GroupPermissionQs:
             group__users=self.user_id, organisation=self.organisation_id
         )
 
+    @property
+    def roles(self) -> typing.List[RolePermissionData]:
+        roles_permission_data: typing.List[RolePermissionData] = (
+            get_roles_permission_data_for_organisation(
+                self.organisation_id, self.user_id
+            )
+        )
+        return roles_permission_data
+
+
+@dataclass
+class _ProjectPermissionService:
+    project_id: int
+    user_id: int
+
+    @property
+    def user_permission(self) -> typing.Optional[UserProjectPermission]:
+        return UserProjectPermission.objects.filter(
+            project_id=self.project_id, user_id=self.user_id
+        ).first()
+
+    @property
+    def group_qs(self) -> GroupPermissionQs:
+        return UserPermissionGroupProjectPermission.objects.filter(
+            group__users=self.user_id, project=self.project_id
+        )
+
+    @property
+    def roles(self) -> typing.List[RolePermissionData]:
+        roles_permission_data: typing.List[RolePermissionData] = (
+            get_roles_permission_data_for_project(self.project_id, self.user_id)
+        )
+        return roles_permission_data
+
 
 @dataclass
 class _EnvironmentPermissionService:
     environment_id: int
     user_id: int
+    project_permission_svc: _ProjectPermissionService
 
     @property
     def user_permission(self) -> typing.Optional[UserEnvironmentPermission]:
@@ -240,20 +398,23 @@ def group_qs(self) -> GroupPermissionQs:
             group__users=self.user_id, environment=self.environment_id
         )
 
+    @property
+    def roles_data(self) -> typing.List[RolePermissionData]:
+        roles_permission_data: typing.List[RolePermissionData] = (
+            get_roles_permission_data_for_environment(self.environment_id, self.user_id)
+        )
+        return roles_permission_data
 
-@dataclass
-class _ProjectPermissionService:
-    project_id: int
-    user_id: int
+    @property
+    def inherited_user_admin(self) -> bool:
+        if user_permission := self.project_permission_svc.user_permission:
+            return user_permission.admin
+        return False
 
     @property
-    def user_permission(self) -> typing.Optional[UserProjectPermission]:
-        return UserProjectPermission.objects.filter(
-            project_id=self.project_id, user_id=self.user_id
-        ).first()
+    def inherited_admin_group_qs(self) -> GroupPermissionQs:
+        return self.project_permission_svc.group_qs.filter(admin=True)
 
     @property
-    def group_qs(self) -> GroupPermissionQs:
-        return UserPermissionGroupProjectPermission.objects.filter(
-            group__users=self.user_id, project=self.project_id
-        )
+    def inherited_admin_roles(self) -> typing.List[RolePermissionData]:
+        return [role for role in self.project_permission_svc.roles if role.admin]
diff --git a/api/permissions/rbac_wrapper.py b/api/permissions/rbac_wrapper.py
index e99526f85d1f..65a53df0af05 100644
--- a/api/permissions/rbac_wrapper.py
+++ b/api/permissions/rbac_wrapper.py
@@ -13,13 +13,11 @@
         get_role_permission_filter,
     )
     from rbac.permissions_calculator import (  # type: ignore[import-not-found,unused-ignore]
-        RolePermissionData,
         get_roles_permission_data_for_environment,
         get_roles_permission_data_for_organisation,
         get_roles_permission_data_for_project,
     )
 else:
-    RolePermissionData = []
 
     def get_roles_permission_data_for_organisation(*args, **kwargs):  # type: ignore[no-untyped-def]
         return []
diff --git a/api/permissions/serializers.py b/api/permissions/serializers.py
index 0d4ce49a2a29..a0456c4e13d4 100644
--- a/api/permissions/serializers.py
+++ b/api/permissions/serializers.py
@@ -46,3 +46,37 @@ class UserObjectPermissionsSerializer(serializers.Serializer):  # type: ignore[t
     permissions = serializers.ListField(child=serializers.CharField())
     admin = serializers.BooleanField()
     tag_based_permissions = TagBasedPermissionSerializer(many=True)
+
+
+class GroupSerializer(serializers.Serializer):  # type: ignore[type-arg]
+    id = serializers.IntegerField()
+    name = serializers.CharField()
+
+
+class PermissionRoleSerializer(serializers.Serializer):  # type: ignore[type-arg]
+    id = serializers.IntegerField()
+    name = serializers.CharField()
+    tags = serializers.ListField(child=serializers.IntegerField(), default=list)
+
+
+class DerivedFromSerializer(serializers.Serializer):  # type: ignore[type-arg]
+    groups = GroupSerializer(many=True)
+    roles = PermissionRoleSerializer(many=True)
+
+
+class BaseDetailedPermissionsSerializer(serializers.Serializer):  # type: ignore[type-arg]
+    is_directly_granted = serializers.BooleanField()
+    derived_from = DerivedFromSerializer()
+
+
+class DetailedPermissionsSerializer(serializers.Serializer):  # type: ignore[type-arg]
+    permission_key = serializers.CharField()
+    is_directly_granted = serializers.BooleanField()
+    derived_from = DerivedFromSerializer()
+
+
+class UserDetailedPermissionsSerializer(serializers.Serializer):  # type: ignore[type-arg]
+    admin = serializers.BooleanField()
+    permissions = DetailedPermissionsSerializer(many=True)
+    is_directly_granted = serializers.BooleanField()
+    derived_from = DerivedFromSerializer()
diff --git a/api/projects/permissions.py b/api/projects/permissions.py
index fe1fa34b1572..68ba520f2162 100644
--- a/api/projects/permissions.py
+++ b/api/projects/permissions.py
@@ -58,7 +58,7 @@ def has_object_permission(self, request, view, obj):  # type: ignore[no-untyped-
         ):
             return True
 
-        if view.action == "user_permissions":
+        if view.action in ["user_permissions", "detailed_permissions"]:
             return True
 
         return False
diff --git a/api/projects/views.py b/api/projects/views.py
index b3738dea0f28..45811e81d8b1 100644
--- a/api/projects/views.py
+++ b/api/projects/views.py
@@ -11,7 +11,7 @@
 from drf_yasg.utils import no_body, swagger_auto_schema  # type: ignore[import-untyped]
 from rest_framework import status, viewsets
 from rest_framework.decorators import action, api_view, permission_classes
-from rest_framework.exceptions import ValidationError
+from rest_framework.exceptions import PermissionDenied, ValidationError
 from rest_framework.generics import get_object_or_404
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.request import Request
@@ -23,6 +23,7 @@
 from permissions.permissions_calculator import get_project_permission_data
 from permissions.serializers import (
     PermissionModelSerializer,
+    UserDetailedPermissionsSerializer,
     UserObjectPermissionsSerializer,
 )
 from projects.exceptions import (
@@ -48,6 +49,7 @@
     ProjectRetrieveSerializer,
     ProjectUpdateSerializer,
 )
+from users.models import FFAdminUser
 
 
 @method_decorator(
@@ -91,7 +93,6 @@ def get_serializer_context(self):  # type: ignore[no-untyped-def]
     def get_queryset(self):  # type: ignore[no-untyped-def]
         if getattr(self, "swagger_fake_view", False):
             return Project.objects.none()
-
         queryset = self.request.user.get_permitted_projects(permission_key=VIEW_PROJECT)  # type: ignore[union-attr]
 
         organisation_id = self.request.query_params.get("organisation")
@@ -141,6 +142,34 @@ def permissions(self, *args, **kwargs):  # type: ignore[no-untyped-def]
             ).data
         )
 
+    @swagger_auto_schema(
+        responses={200: UserDetailedPermissionsSerializer},
+    )  # type: ignore[misc]
+    @action(
+        detail=True,
+        methods=["GET"],
+        url_path=r"user-detailed-permissions/(?P<user_id>\d+)",
+        url_name="user-detailed-permissions",
+    )
+    def detailed_permissions(self, request: Request, pk: str, user_id: str) -> Response:
+        project = self.get_object()
+        user = request.user
+        user_id_int = int(user_id)
+
+        if request.user.id != user_id_int:
+            if not request.user.is_project_admin(project):  # type: ignore[union-attr]
+                # Only project admin can get permissions of other users
+                raise PermissionDenied()
+
+            user = get_object_or_404(FFAdminUser, id=user_id_int)
+
+        permission_data = get_project_permission_data(project, user)  # type: ignore[arg-type]
+
+        serializer = UserDetailedPermissionsSerializer(
+            permission_data.to_detailed_permissions_data()
+        )
+        return Response(serializer.data)
+
     @swagger_auto_schema(responses={200: UserObjectPermissionsSerializer()})  # type: ignore[misc]
     @action(
         detail=True,
@@ -156,7 +185,9 @@ def user_permissions(self, request: Request, pk: int = None):  # type: ignore[no
                     "detail": "This endpoint can only be used with a user and not Master API Key"
                 },
             )
-        permission_data = get_project_permission_data(pk, user_id=request.user.id)
+
+        project = self.get_object()
+        permission_data = get_project_permission_data(project, user=request.user)  # type: ignore[arg-type]
         serializer = UserObjectPermissionsSerializer(instance=permission_data)
         return Response(serializer.data)
 
@@ -236,8 +267,10 @@ def get_serializer_class(self):  # type: ignore[no-untyped-def]
 @permission_classes([IsAuthenticated, IsProjectAdmin])
 def get_user_project_permissions(request, **kwargs):  # type: ignore[no-untyped-def]
     user_id = kwargs["user_pk"]
+    project = get_object_or_404(Project, pk=kwargs["project_pk"])
+    user = get_object_or_404(FFAdminUser, pk=user_id)
 
-    permission_data = get_project_permission_data(kwargs["project_pk"], user_id=user_id)
+    permission_data = get_project_permission_data(project, user=user)
     # TODO: expose `user` and `groups` attributes from user_permissions_data
     serializer = UserObjectPermissionsSerializer(instance=permission_data)
     return Response(serializer.data)
diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py
index e11aa8e4f41f..6929a1717d40 100644
--- a/api/tests/unit/environments/test_unit_environments_views.py
+++ b/api/tests/unit/environments/test_unit_environments_views.py
@@ -296,6 +296,87 @@ def test_environment_user_can_get_their_permissions(
     assert "VIEW_ENVIRONMENT" in response.json()["permissions"]
 
 
+def test_environment_user_can_get_their_detailed_permissions(
+    staff_client: APIClient,
+    with_environment_permissions: WithEnvironmentPermissionsCallable,
+    environment: Environment,
+    staff_user: FFAdminUser,
+) -> None:
+    # Given
+    with_environment_permissions([VIEW_ENVIRONMENT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:environments:environment-user-detailed-permissions",
+        args=[environment.api_key, staff_user.id],
+    )
+
+    # When
+    response = staff_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["admin"] is False
+    assert response.json()["is_directly_granted"] is False
+    assert response.json()["derived_from"] == {"groups": [], "roles": []}
+    assert response.json()["permissions"] == [
+        {
+            "permission_key": "VIEW_ENVIRONMENT",
+            "is_directly_granted": True,
+            "derived_from": {"groups": [], "roles": []},
+        }
+    ]
+
+
+def test_environment_user_can_not_get_detailed_permissions_of_other_user(
+    staff_client: APIClient,
+    with_environment_permissions: WithEnvironmentPermissionsCallable,
+    environment: Environment,
+    admin_user: FFAdminUser,
+) -> None:
+    # Given
+    with_environment_permissions([VIEW_ENVIRONMENT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:environments:environment-user-detailed-permissions",
+        args=[environment.api_key, admin_user.id],
+    )
+
+    # When
+    response = staff_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_environment_admin_can_get_detailed_permissions_of_other_user(
+    admin_client: APIClient,
+    with_environment_permissions: WithEnvironmentPermissionsCallable,
+    environment: Environment,
+    admin_user: FFAdminUser,
+    staff_user: FFAdminUser,
+) -> None:
+    # Given
+    with_environment_permissions([VIEW_ENVIRONMENT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:environments:environment-user-detailed-permissions",
+        args=[environment.api_key, staff_user.id],
+    )
+
+    # When
+    response = admin_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["admin"] is False
+    assert response.json()["is_directly_granted"] is False
+    assert response.json()["derived_from"] == {"groups": [], "roles": []}
+    assert response.json()["permissions"] == [
+        {
+            "permission_key": "VIEW_ENVIRONMENT",
+            "is_directly_granted": True,
+            "derived_from": {"groups": [], "roles": []},
+        }
+    ]
+
+
 def test_can_create_webhook_for_an_environment(
     environment: Environment,
     admin_client_new: APIClient,
diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py
index c93c7465c60d..a3ecc32993c3 100644
--- a/api/tests/unit/organisations/test_unit_organisations_views.py
+++ b/api/tests/unit/organisations/test_unit_organisations_views.py
@@ -50,6 +50,9 @@
 )
 from projects.models import Project, UserProjectPermission
 from segments.models import Segment
+from tests.types import (
+    WithOrganisationPermissionsCallable,
+)
 from users.models import (
     FFAdminUser,
     HubspotTracker,
@@ -2059,3 +2062,84 @@ def test_validation_error_if_non_numeric_organisation_id(
 
     # Then
     assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+
+def test_organisation_user_can_get_their_detailed_permissions(
+    staff_client: APIClient,
+    with_organisation_permissions: WithOrganisationPermissionsCallable,
+    organisation: Organisation,
+    staff_user: FFAdminUser,
+) -> None:
+    # Given
+    with_organisation_permissions([CREATE_PROJECT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:organisations:organisation-user-detailed-permissions",
+        args=[organisation.id, staff_user.id],
+    )
+
+    # When
+    response = staff_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["admin"] is False
+    assert response.json()["is_directly_granted"] is False
+    assert response.json()["derived_from"] == {"groups": [], "roles": []}
+    assert response.json()["permissions"] == [
+        {
+            "permission_key": "CREATE_PROJECT",
+            "is_directly_granted": True,
+            "derived_from": {"groups": [], "roles": []},
+        }
+    ]
+
+
+def test_organisation_user_can_not_get_detailed_permissions_of_other_user(
+    staff_client: APIClient,
+    with_organisation_permissions: WithOrganisationPermissionsCallable,
+    organisation: Organisation,
+    admin_user: FFAdminUser,
+) -> None:
+    # Given
+    with_organisation_permissions([CREATE_PROJECT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:organisations:organisation-user-detailed-permissions",
+        args=[organisation.id, admin_user.id],
+    )
+
+    # When
+    response = staff_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_organisation_admin_can_get_detailed_permissions_of_other_user(
+    admin_client: APIClient,
+    with_organisation_permissions: WithOrganisationPermissionsCallable,
+    organisation: Organisation,
+    admin_user: FFAdminUser,
+    staff_user: FFAdminUser,
+) -> None:
+    # Given
+    with_organisation_permissions([CREATE_PROJECT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:organisations:organisation-user-detailed-permissions",
+        args=[organisation.id, staff_user.id],
+    )
+
+    # When
+    response = admin_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["admin"] is False
+    assert response.json()["is_directly_granted"] is False
+    assert response.json()["derived_from"] == {"groups": [], "roles": []}
+    assert response.json()["permissions"] == [
+        {
+            "permission_key": "CREATE_PROJECT",
+            "is_directly_granted": True,
+            "derived_from": {"groups": [], "roles": []},
+        }
+    ]
diff --git a/api/tests/unit/permissions/test_unit_permissions_calculator.py b/api/tests/unit/permissions/test_unit_permissions_calculator.py
index 1b64aa510bb3..73837159cdc9 100644
--- a/api/tests/unit/permissions/test_unit_permissions_calculator.py
+++ b/api/tests/unit/permissions/test_unit_permissions_calculator.py
@@ -1,19 +1,20 @@
 import pytest
 from common.environments.permissions import (
+    CREATE_CHANGE_REQUEST,
+    MANAGE_IDENTITIES,
     UPDATE_FEATURE_STATE,
     VIEW_ENVIRONMENT,
 )
-from common.projects.permissions import (
-    CREATE_ENVIRONMENT,
-    VIEW_PROJECT,
-)
+from common.projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT
+from pytest_mock import MockerFixture
 
+from environments.models import Environment
 from environments.permissions.models import (
     EnvironmentPermissionModel,
     UserEnvironmentPermission,
     UserPermissionGroupEnvironmentPermission,
 )
-from organisations.models import OrganisationRole
+from organisations.models import Organisation, OrganisationRole
 from organisations.permissions.models import (
     OrganisationPermissionModel,
     UserOrganisationPermission,
@@ -24,16 +25,23 @@
     MANAGE_USER_GROUPS,
 )
 from permissions.permissions_calculator import (
+    GroupData,
+    GroupPermissionData,
+    PermissionData,
+    RoleData,
+    RolePermissionData,
+    UserPermissionData,
     get_environment_permission_data,
     get_organisation_permission_data,
     get_project_permission_data,
 )
 from projects.models import (
+    Project,
     ProjectPermissionModel,
     UserPermissionGroupProjectPermission,
     UserProjectPermission,
 )
-from users.models import UserPermissionGroup
+from users.models import FFAdminUser, UserPermissionGroup
 
 
 @pytest.mark.parametrize(
@@ -102,11 +110,32 @@ def test_project_permissions_calculator_get_permission_data(  # type: ignore[no-
         group_project_permission.permissions.add(project_permissions[permission_key])
 
     # When
-    user_permission_data = get_project_permission_data(project.id, user_id=user.id)
+    user_permission_data = get_project_permission_data(project, user=user)
 
     # Then
     assert user_permission_data.admin == expected_admin
     assert user_permission_data.permissions == expected_permissions
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == []
+    assert user_permission_data.inherited_admin_roles == []
+
+
+def test_project_permissions_calculator_get_permission_data_for_organisation_admin(
+    project: Project, organisation: Organisation, django_user_model: FFAdminUser
+) -> None:
+    # Given
+    user = django_user_model.objects.create(email="test@example.com")
+    user.add_organisation(organisation, OrganisationRole.ADMIN)
+
+    # When
+    user_permission_data = get_project_permission_data(project, user=user)
+
+    # Then
+    assert user_permission_data.admin is True
+    assert user_permission_data.permissions == set()
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == []
+    assert user_permission_data.inherited_admin_roles == []
 
 
 @pytest.mark.parametrize(
@@ -204,11 +233,17 @@ def test_environment_permissions_calculator_get_permission_data(  # type: ignore
     # Then
     assert user_permission_data.admin == expected_admin
     assert user_permission_data.permissions == expected_permissions
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == []
+    assert user_permission_data.inherited_admin_roles == []
 
 
-def test_environment_permissions_calculator_returns_admin_for_project_admin(  # type: ignore[no-untyped-def]
-    environment, project, organisation, django_user_model
-):
+def test_environment_permissions_calculator_get_permission_data_for_direct_project_admin(
+    environment: Environment,
+    project: Project,
+    organisation: Organisation,
+    django_user_model: FFAdminUser,
+) -> None:
     # Given
     user = django_user_model.objects.create(email="test@example.com")
     user.add_organisation(organisation, OrganisationRole.USER)
@@ -224,6 +259,103 @@ def test_environment_permissions_calculator_returns_admin_for_project_admin(  #
     assert user_permission_data.admin is True
 
 
+def test_environment_permissions_calculator_get_permission_data_for_project_admin_through_group(
+    environment: Environment,
+    project: Project,
+    organisation: Organisation,
+    django_user_model: FFAdminUser,
+) -> None:
+    # Given
+    user = django_user_model.objects.create(email="test@example.com")
+    user.add_organisation(organisation, OrganisationRole.USER)
+    group = UserPermissionGroup.objects.create(
+        name="Test Group", organisation=organisation
+    )
+    group.users.add(user)
+
+    UserPermissionGroupProjectPermission.objects.create(
+        group=group, project=project, admin=True
+    )
+    # When
+    user_permission_data = get_environment_permission_data(
+        environment=environment, user=user
+    )
+
+    # Then
+    assert user_permission_data.admin is True
+    assert user_permission_data.permissions == set()
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == [
+        GroupPermissionData(
+            group=GroupData(id=group.id, name=group.name), admin=True, permissions=set()
+        )
+    ]
+    assert user_permission_data.inherited_admin_roles == []
+
+
+def test_environment_permissions_calculator_get_permission_data_for_project_admin_through_role(
+    environment: Environment,
+    project: Project,
+    organisation: Organisation,
+    django_user_model: FFAdminUser,
+    mocker: MockerFixture,
+) -> None:
+    # Given
+    user = django_user_model.objects.create(email="test@example.com")
+    user.add_organisation(organisation, OrganisationRole.USER)
+
+    admin_role = RolePermissionData(
+        role=RoleData(id=1, name="Admin Role", tags=set()),
+        admin=True,
+        permissions=set(),
+    )
+    non_admin_role = RolePermissionData(
+        role=RoleData(id=2, name="View only Role", tags=set()),
+        admin=False,
+        permissions={VIEW_PROJECT},
+    )
+    mocker.patch(
+        "permissions.permissions_calculator.get_roles_permission_data_for_project",
+        return_value=[
+            non_admin_role,
+            admin_role,
+        ],
+    )
+    # When
+    user_permission_data = get_environment_permission_data(
+        environment=environment, user=user
+    )
+
+    # Then
+    assert user_permission_data.admin is True
+    assert user_permission_data.permissions == set()
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == []
+    assert user_permission_data.inherited_admin_roles == [admin_role]
+
+
+def test_environment_permissions_calculator_get_permission_data_for_organisation_admin(
+    environment: Environment,
+    organisation: Organisation,
+    django_user_model: FFAdminUser,
+) -> None:
+    # Given
+    user = django_user_model.objects.create(email="test@example.com")
+    user.add_organisation(organisation, OrganisationRole.ADMIN)
+
+    # When
+    user_permission_data = get_environment_permission_data(
+        environment=environment, user=user
+    )
+
+    # Then
+    assert user_permission_data.admin is True
+    assert user_permission_data.permissions == set()
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == []
+    assert user_permission_data.inherited_admin_roles == []
+
+
 @pytest.mark.parametrize(
     (
         "user_permissions, user_admin, group_permissions, "
@@ -294,3 +426,126 @@ def test_organisation_permissions_calculator_get_permission_data(  # type: ignor
     # Then
     assert user_permission_data.admin == expected_admin
     assert user_permission_data.permissions == expected_permissions
+    assert user_permission_data.admin_override is False
+    assert user_permission_data.inherited_admin_groups == []
+    assert user_permission_data.inherited_admin_roles == []
+
+
+def test_permission_data_to_detailed_permissions_data() -> None:
+    # Given
+    user_permission_data = UserPermissionData(
+        admin=True, permissions={CREATE_CHANGE_REQUEST}
+    )
+    # two groups with some overallping permissions
+    group_one_permission_data = GroupPermissionData(
+        admin=True,
+        group=GroupData(id=1, name="group_one"),
+        permissions={CREATE_CHANGE_REQUEST, VIEW_ENVIRONMENT},
+    )
+    group_two_permission_data = GroupPermissionData(
+        admin=True,
+        group=GroupData(id=2, name="group_two"),
+        permissions={VIEW_ENVIRONMENT, MANAGE_IDENTITIES},
+    )
+    # another inherited admin group
+    inherited_admin_group_permission_data = GroupPermissionData(
+        admin=True,
+        group=GroupData(id=3, name="group_three"),
+        permissions={MANAGE_IDENTITIES, VIEW_ENVIRONMENT},
+    )
+
+    # two roles with same permissions with different tags
+    role_one_permission_data = RolePermissionData(
+        admin=True,
+        role=RoleData(id=1, name="role_one", tags={1, 2}),
+        permissions={UPDATE_FEATURE_STATE},
+    )
+    role_two_permission_data = RolePermissionData(
+        admin=False,
+        role=RoleData(id=2, name="role_two", tags={3, 4}),
+        permissions={UPDATE_FEATURE_STATE},
+    )
+    # third role without tags
+    role_three_permission_data = RolePermissionData(
+        admin=False,
+        role=RoleData(id=3, name="role_three", tags=set()),
+        permissions={VIEW_ENVIRONMENT},
+    )
+    # another inherited admin role
+    inherited_admin_role_permission_data = RolePermissionData(
+        admin=True,
+        role=RoleData(id=4, name="role_four", tags=set()),
+        permissions={MANAGE_IDENTITIES, VIEW_ENVIRONMENT},
+    )
+
+    expected_permissions = {
+        CREATE_CHANGE_REQUEST: {
+            "is_directly_granted": True,
+            "derived_from": {
+                "groups": [group_one_permission_data.group],
+                "roles": [],
+            },
+        },
+        VIEW_ENVIRONMENT: {
+            "is_directly_granted": False,
+            "derived_from": {
+                "groups": [
+                    group_one_permission_data.group,
+                    group_two_permission_data.group,
+                ],
+                "roles": [role_three_permission_data.role],
+            },
+        },
+        MANAGE_IDENTITIES: {
+            "is_directly_granted": False,
+            "derived_from": {
+                "groups": [group_two_permission_data.group],
+                "roles": [],
+            },
+        },
+        UPDATE_FEATURE_STATE: {
+            "is_directly_granted": False,
+            "derived_from": {
+                "groups": [],
+                "roles": [
+                    role_one_permission_data.role,
+                    role_two_permission_data.role,
+                ],
+            },
+        },
+    }
+
+    # When
+    detailed_permission_data = PermissionData(
+        user=user_permission_data,
+        groups=[group_one_permission_data, group_two_permission_data],
+        inherited_admin_groups=[inherited_admin_group_permission_data],
+        inherited_admin_roles=[inherited_admin_role_permission_data],
+        roles=[
+            role_one_permission_data,
+            role_two_permission_data,
+            role_three_permission_data,
+        ],
+    ).to_detailed_permissions_data()
+    # Then
+
+    assert len(detailed_permission_data.permissions) == 4
+    for permission in detailed_permission_data.permissions:
+        assert permission.permission_key in expected_permissions
+        expected = expected_permissions[permission.permission_key]
+        assert permission.is_directly_granted == expected["is_directly_granted"]
+        assert permission.derived_from.groups == expected["derived_from"]["groups"]  # type: ignore[index]
+        assert permission.derived_from.roles == expected["derived_from"]["roles"]  # type: ignore[index]
+
+    assert detailed_permission_data.admin is True
+    assert detailed_permission_data.is_directly_granted is True
+
+    assert detailed_permission_data.derived_from.groups == [
+        inherited_admin_group_permission_data.group,
+        group_one_permission_data.group,
+        group_two_permission_data.group,
+    ]
+    assert detailed_permission_data.derived_from.roles == [
+        inherited_admin_role_permission_data.role,
+        role_one_permission_data.role,
+    ]
diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py
index 4916273825e4..1ed05afc2b31 100644
--- a/api/tests/unit/projects/test_unit_projects_views.py
+++ b/api/tests/unit/projects/test_unit_projects_views.py
@@ -923,3 +923,84 @@ def test_can_create_project_with_duplicate_name_in_another_organisation(
 
     # Then
     assert response.status_code == status.HTTP_201_CREATED
+
+
+def test_project_user_can_get_their_detailed_permissions(
+    staff_client: APIClient,
+    with_project_permissions: WithProjectPermissionsCallable,
+    project: Project,
+    staff_user: FFAdminUser,
+) -> None:
+    # Given
+    with_project_permissions([VIEW_PROJECT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:projects:project-user-detailed-permissions",
+        args=[project.id, staff_user.id],
+    )
+
+    # When
+    response = staff_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["admin"] is False
+    assert response.json()["is_directly_granted"] is False
+    assert response.json()["derived_from"] == {"groups": [], "roles": []}
+    assert response.json()["permissions"] == [
+        {
+            "permission_key": "VIEW_PROJECT",
+            "is_directly_granted": True,
+            "derived_from": {"groups": [], "roles": []},
+        }
+    ]
+
+
+def test_project_user_can_not_get_detailed_permissions_of_other_user(
+    staff_client: APIClient,
+    with_project_permissions: WithProjectPermissionsCallable,
+    project: Project,
+    admin_user: FFAdminUser,
+) -> None:
+    # Given
+    with_project_permissions([VIEW_PROJECT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:projects:project-user-detailed-permissions",
+        args=[project.id, admin_user.id],
+    )
+
+    # When
+    response = staff_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_project_admin_can_get_detailed_permissions_of_other_user(
+    admin_client: APIClient,
+    with_project_permissions: WithProjectPermissionsCallable,
+    project: Project,
+    admin_user: FFAdminUser,
+    staff_user: FFAdminUser,
+) -> None:
+    # Given
+    with_project_permissions([VIEW_PROJECT])  # type: ignore[call-arg]
+    url = reverse(
+        "api-v1:projects:project-user-detailed-permissions",
+        args=[project.id, staff_user.id],
+    )
+
+    # When
+    response = admin_client.get(url)
+
+    # Then
+    assert response.status_code == status.HTTP_200_OK
+    assert response.json()["admin"] is False
+    assert response.json()["is_directly_granted"] is False
+    assert response.json()["derived_from"] == {"groups": [], "roles": []}
+    assert response.json()["permissions"] == [
+        {
+            "permission_key": "VIEW_PROJECT",
+            "is_directly_granted": True,
+            "derived_from": {"groups": [], "roles": []},
+        }
+    ]


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR implements new endpoints and corresponding tests for retrieving detailed permissions information for projects, organisations, and environments to aid in permission debugging. Key changes include:
- Addition of new detailed permissions endpoints and related test cases.
- Updates to serializers and permission calculation logic to support the detailed permissions structure.
- Modifications to views and permission checks to ensure only authorized users can access detailed permission information.

### Reviewed Changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| api/tests/unit/projects/test_unit_projects_views.py | Added tests for detailed permissions endpoint for projects. |
| api/tests/unit/permissions/test_unit_permissions_calculator.py | Added a new test for converting permission data to detailed permissions, along with updates for role permission data. |
| api/tests/unit/organisations/test_unit_organisations_views.py | Added tests for detailed permissions endpoint for organisations. |
| api/tests/unit/environments/test_unit_environments_views.py | Added tests for detailed permissions endpoint for environments. |
| api/projects/views.py | Introduced a new action (detailed_permissions) to get detailed permissions for a user. |
| api/projects/permissions.py | Extended permission check to include the detailed_permissions action. |
| api/permissions/serializers.py | Added detailed permissions serializers to support the new endpoint responses. |
| api/permissions/rbac_wrapper.py | Removed an unused RolePermissionData import. |
| api/permissions/permissions_calculator.py | Updated permission data classes and added a method to convert permission data into detailed permissions data. |
| api/organisations/views.py | Added a detailed_permissions endpoint for organisations. |
| api/organisations/permissions/permissions.py | Updated permission checks for detailed permissions in organisations. |
| api/environments/views.py | Added a detailed_permissions endpoint for environments. |
| api/environments/permissions/permissions.py | Updated permission checks for detailed permissions in environments. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T08:06:49Z
user: login: Zaimwa9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:12:43Z
user: login: Zaimwa9
body: Strong feature and improvement overall (including frontend).

Although there is a deduplication issue to solve and some suggestions on simplifying/encapsulating more the logic in `to_detailed_permissions_data` where most of the complexity lives now
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:28:48Z
user: login: gagantrivedi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T11:08:26Z
user: login: gagantrivedi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T11:09:01Z
user: login: Zaimwa9
body: 👍 Thanks for changes on to_detailed_permissions_data (and the rbac fix), imo it became really easy to understand the logic behind. 

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T13:09:12Z

Review comments:
diff_hunk: @@ -294,3 +297,83 @@ def test_organisation_permissions_calculator_get_permission_data(  # type: ignor
     # Then
     assert user_permission_data.admin == expected_admin
     assert user_permission_data.permissions == expected_permissions
+
+
+def test_permission_data_to_detailed_permissions_data() -> None:
+    # Given
+    user_permission_data = UserPermissionData(admin=True, permissions={CREATE_PROJECT})
+    # two groups with some overallping permissions
+    group_one_permission_data = GroupPermissionData(
+        admin=True,
+        group=GroupData(id=1, name="group_one"),
+        permissions={CREATE_PROJECT, VIEW_PROJECT},
+    )
+    group_two_permission_data = GroupPermissionData(
+        admin=True,
+        group=GroupData(id=1, name="group_one"),
+        permissions={VIEW_PROJECT, MANAGE_USER_GROUPS},
+    )
+    # two roles with same permissions with different tags
+    role_one_permission_data = RolePermissionData(
+        admin=True,
+        role=RoleData(id=1, name="role_one", tags={1, 2}),
+        permissions={DELETE_FEATURE},
+    )
+    role_two_permission_data = RolePermissionData(
+        admin=False,
+        role=RoleData(id=2, name="role_two", tags={3, 4}),
+        permissions={DELETE_FEATURE},
+    )
+    # third role without tags
+    role_three_permission_data = RolePermissionData(
+        admin=False,
+        role=RoleData(id=3, name="role_three", tags=set()),
+        permissions={VIEW_PROJECT},
+    )
+
+    # When
+    detailed_permission_data = PermissionData(
+        user=user_permission_data,
+        groups=[group_one_permission_data, group_two_permission_data],
+        roles=[
+            role_one_permission_data,
+            role_two_permission_data,
+            role_three_permission_data,
+        ],
+    ).to_detailed_permissions_data()
+
+    # Then
+    assert detailed_permission_data.admin is True
+    assert len(detailed_permission_data.permissions) == 4
+
+    assert detailed_permission_data.permissions[0].permission_key == CREATE_PROJECT
+    assert detailed_permission_data.permissions[0].is_directly_granted is True
+    assert detailed_permission_data.permissions[0].derived_from.groups == [
+        group_one_permission_data.group
+    ]
+    assert detailed_permission_data.permissions[0].derived_from.roles == []
+
+    assert detailed_permission_data.permissions[1].permission_key == VIEW_PROJECT
+    assert detailed_permission_data.permissions[1].is_directly_granted is False
+    assert detailed_permission_data.permissions[1].derived_from.groups == [
+        group_one_permission_data.group,
+        group_two_permission_data.group,
+    ]
+    assert detailed_permission_data.permissions[1].derived_from.roles == [
+        role_three_permission_data.role
+    ]
+
+    assert detailed_permission_data.permissions[2].permission_key == MANAGE_USER_GROUPS
+    assert detailed_permission_data.permissions[2].is_directly_granted is False
+    assert detailed_permission_data.permissions[2].derived_from.groups == [
+        group_two_permission_data.group
+    ]
+    assert detailed_permission_data.permissions[2].derived_from.roles == []
+
+    assert detailed_permission_data.permissions[3].permission_key == DELETE_FEATURE
+    assert detailed_permission_data.permissions[3].is_directly_granted is False
+    assert detailed_permission_data.permissions[3].derived_from.groups == []
+    assert detailed_permission_data.permissions[3].derived_from.roles == [
+        role_one_permission_data.role,
+        role_two_permission_data.role,
+    ]
path: api/tests/unit/permissions/test_unit_permissions_calculator.py
user: login: Copilot
body: The test relies on a fixed ordering of permissions from a dictionary conversion, which may not be guaranteed. Consider sorting the list or verifying the presence of each expected permission regardless of order.
```suggestion
    expected_permissions = {
        CREATE_PROJECT: {
            "is_directly_granted": True,
            "derived_from": {
                "groups": [group_one_permission_data.group],
                "roles": [],
            },
        },
        VIEW_PROJECT: {
            "is_directly_granted": False,
            "derived_from": {
                "groups": [
                    group_one_permission_data.group,
                    group_two_permission_data.group,
                ],
                "roles": [role_three_permission_data.role],
            },
        },
        MANAGE_USER_GROUPS: {
            "is_directly_granted": False,
            "derived_from": {
                "groups": [group_two_permission_data.group],
                "roles": [],
            },
        },
        DELETE_FEATURE: {
            "is_directly_granted": False,
            "derived_from": {
                "groups": [],
                "roles": [
                    role_one_permission_data.role,
                    role_two_permission_data.role,
                ],
            },
        },
    }

    for permission in detailed_permission_data.permissions:
        assert permission.permission_key in expected_permissions
        expected = expected_permissions[permission.permission_key]
        assert permission.is_directly_granted == expected["is_directly_granted"]
        assert permission.derived_from.groups == expected["derived_from"]["groups"]
        assert permission.derived_from.roles == expected["derived_from"]["roles"]
```
created_at: 2025-05-01T08:06:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,7 +28,7 @@
     from environments.models import Environment
     from users.models import FFAdminUser
 
-
+# Some random comment
path: api/permissions/permissions_calculator.py
user: login: Zaimwa9
body: ```suggestion
```
created_at: 2025-05-07T14:12:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,14 +164,67 @@ def tag_based_permissions(self) -> list[dict]:  # type: ignore[type-arg]
             if role_permission.role.tags
         ]
 
+    def to_detailed_permissions_data(self) -> UserDetailedPermissionsData:  # noqa: C901
+        permission_map = {}
+        is_admin_permission_directly_granted = False
+        admin_permission_derived_from = PermissionDerivedFromData(
+            groups=[
+                group_permission.group
+                for group_permission in self.inherited_admin_groups
+            ],
+            roles=[
+                role_permission.role for role_permission in self.inherited_admin_roles
+            ],
+        )
 
-def get_project_permission_data(project_id: int, user_id: int) -> PermissionData:
-    project_permission_svc = _ProjectPermissionService(project_id, user_id)
-    return PermissionData(
-        groups=get_groups_permission_data(project_permission_svc.group_qs),
-        user=get_user_permission_data(project_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_project(project_id, user_id),
-    )
+        def add_permission(
+            permission_key: str,
+            group: typing.Optional[GroupData],
+            role: typing.Optional[RoleData],
+        ) -> None:
+            if permission_key not in permission_map:
+                permission_map[permission_key] = DetailedPermissionsData(
+                    permission_key=permission_key,
+                    is_directly_granted=bool(group is None and role is None),
+                    derived_from=PermissionDerivedFromData(),
+                )
+            if group:
+                permission_map[permission_key].derived_from.groups.append(group)
+            if role:
+                permission_map[permission_key].derived_from.roles.append(role)
path: api/permissions/permissions_calculator.py
user: login: Zaimwa9
body: Duplication of derived roles actually come from here (cf [thread](https://github.com/Flagsmith/flagsmith/pull/5375#discussion_r2079967616)). 

The root cause is in rbac that is returning a duplicated list. 

But here we should ensure deduplication of the payload until it's completely fix there (or maybe that was intended)
created_at: 2025-05-12T09:29:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,14 +164,67 @@ def tag_based_permissions(self) -> list[dict]:  # type: ignore[type-arg]
             if role_permission.role.tags
         ]
 
+    def to_detailed_permissions_data(self) -> UserDetailedPermissionsData:  # noqa: C901
+        permission_map = {}
+        is_admin_permission_directly_granted = False
+        admin_permission_derived_from = PermissionDerivedFromData(
+            groups=[
+                group_permission.group
+                for group_permission in self.inherited_admin_groups
+            ],
+            roles=[
+                role_permission.role for role_permission in self.inherited_admin_roles
+            ],
+        )
 
-def get_project_permission_data(project_id: int, user_id: int) -> PermissionData:
-    project_permission_svc = _ProjectPermissionService(project_id, user_id)
-    return PermissionData(
-        groups=get_groups_permission_data(project_permission_svc.group_qs),
-        user=get_user_permission_data(project_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_project(project_id, user_id),
-    )
+        def add_permission(
+            permission_key: str,
+            group: typing.Optional[GroupData],
+            role: typing.Optional[RoleData],
+        ) -> None:
+            if permission_key not in permission_map:
+                permission_map[permission_key] = DetailedPermissionsData(
+                    permission_key=permission_key,
+                    is_directly_granted=bool(group is None and role is None),
+                    derived_from=PermissionDerivedFromData(),
+                )
+            if group:
+                permission_map[permission_key].derived_from.groups.append(group)
+            if role:
+                permission_map[permission_key].derived_from.roles.append(role)
+
+        # Add user's direct permissions
+        for permission_key in self.user.permissions:
+            if self.user.admin:
+                is_admin_permission_directly_granted = True
+
+            add_permission(permission_key, None, None)
+
+        # Add group permissions
+        for group_permission in self.groups:
+            if group_permission.admin:
+                admin_permission_derived_from.groups.append(group_permission.group)
+
+            for permission_key in group_permission.permissions:
+                add_permission(permission_key, group_permission.group, None)
+
+        # Add role permissions
+        for role_permission in self.roles:
+            if role_permission.admin:
+                admin_permission_derived_from.roles.append(role_permission.role)
+
+            for permission_key in role_permission.permissions:
+                add_permission(permission_key, None, role_permission.role)
+
+        if self.is_organisation_admin or self.user.admin or self.admin_override:
path: api/permissions/permissions_calculator.py
user: login: Zaimwa9
body: This function is working well but it's also quite dense and doing a lot of things (admin resolution, formatting, permission origin, deduplication soon).

It might be worth extracting that into a dedicated PermissionAggregator-like class that could:
- abstract the different pathes
- encapsulate more the user/role/group logic
- `add_permission` being a private method 

Its goal would be to construct the graph and have a final `build` method returning the `UserDetailedPermissionsData`
created_at: 2025-05-12T11:51:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +82,27 @@ class GroupPermissionData(_PermissionDataBase, _GroupPermissionBase):
     pass
 
 
+@dataclass
+class PermissionDerivedFromData:
path: api/permissions/permissions_calculator.py
user: login: Zaimwa9
body: One way to handle deduplication without doing a linear scan on the arrays before appending could be to internally use dicts here. Have setter methods to add a group/user and build the list with the deduped values once the permission_map is complete wdyt ?

```
    _group_map: dict[int, GroupData] # Int being the id
    _role_map: dict[int, RoleData]
    
    def build_derived_from(self) -> None:
         self.groups = list(self._group_map.values())
         self.roles = list(self._role_map.values())
``` 
created_at: 2025-05-12T11:57:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,14 +164,67 @@ def tag_based_permissions(self) -> list[dict]:  # type: ignore[type-arg]
             if role_permission.role.tags
         ]
 
+    def to_detailed_permissions_data(self) -> UserDetailedPermissionsData:  # noqa: C901
+        permission_map = {}
+        is_admin_permission_directly_granted = False
+        admin_permission_derived_from = PermissionDerivedFromData(
+            groups=[
+                group_permission.group
+                for group_permission in self.inherited_admin_groups
+            ],
+            roles=[
+                role_permission.role for role_permission in self.inherited_admin_roles
+            ],
+        )
 
-def get_project_permission_data(project_id: int, user_id: int) -> PermissionData:
-    project_permission_svc = _ProjectPermissionService(project_id, user_id)
-    return PermissionData(
-        groups=get_groups_permission_data(project_permission_svc.group_qs),
-        user=get_user_permission_data(project_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_project(project_id, user_id),
-    )
+        def add_permission(
+            permission_key: str,
+            group: typing.Optional[GroupData],
+            role: typing.Optional[RoleData],
+        ) -> None:
+            if permission_key not in permission_map:
+                permission_map[permission_key] = DetailedPermissionsData(
+                    permission_key=permission_key,
+                    is_directly_granted=bool(group is None and role is None),
+                    derived_from=PermissionDerivedFromData(),
+                )
+            if group:
+                permission_map[permission_key].derived_from.groups.append(group)
+            if role:
+                permission_map[permission_key].derived_from.roles.append(role)
+
+        # Add user's direct permissions
+        for permission_key in self.user.permissions:
+            if self.user.admin:
+                is_admin_permission_directly_granted = True
+
+            add_permission(permission_key, None, None)
+
+        # Add group permissions
+        for group_permission in self.groups:
+            if group_permission.admin:
+                admin_permission_derived_from.groups.append(group_permission.group)
+
+            for permission_key in group_permission.permissions:
+                add_permission(permission_key, group_permission.group, None)
+
+        # Add role permissions
+        for role_permission in self.roles:
+            if role_permission.admin:
+                admin_permission_derived_from.roles.append(role_permission.role)
+
+            for permission_key in role_permission.permissions:
+                add_permission(permission_key, None, role_permission.role)
+
+        if self.is_organisation_admin or self.user.admin or self.admin_override:
path: api/permissions/permissions_calculator.py
user: login: Zaimwa9
body: To give some precisions on what I have in mind, it could be used as follow:

```
     aggregator= PermissionAggregator()

    for group_permission in self.groups:
        for permission_key in group_permissions.permissions:
            aggregator.add_group_permission(permission_key, group_permission.group, is_admin=group_data.admin)
            
            
        ... similar for roles and users
        
        
        return agg.build(is_admin=self.admin)
```
created_at: 2025-05-12T12:06:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +82,27 @@ class GroupPermissionData(_PermissionDataBase, _GroupPermissionBase):
     pass
 
 
+@dataclass
+class PermissionDerivedFromData:
path: api/permissions/permissions_calculator.py
user: login: gagantrivedi
body: Yeah, good shout! this should be done now
created_at: 2025-05-14T11:08:26Z
author_association: MEMBER
diff_hunk: @@ -127,14 +164,67 @@ def tag_based_permissions(self) -> list[dict]:  # type: ignore[type-arg]
             if role_permission.role.tags
         ]
 
+    def to_detailed_permissions_data(self) -> UserDetailedPermissionsData:  # noqa: C901
+        permission_map = {}
+        is_admin_permission_directly_granted = False
+        admin_permission_derived_from = PermissionDerivedFromData(
+            groups=[
+                group_permission.group
+                for group_permission in self.inherited_admin_groups
+            ],
+            roles=[
+                role_permission.role for role_permission in self.inherited_admin_roles
+            ],
+        )
 
-def get_project_permission_data(project_id: int, user_id: int) -> PermissionData:
-    project_permission_svc = _ProjectPermissionService(project_id, user_id)
-    return PermissionData(
-        groups=get_groups_permission_data(project_permission_svc.group_qs),
-        user=get_user_permission_data(project_permission_svc.user_permission),  # type: ignore[arg-type]
-        roles=get_roles_permission_data_for_project(project_id, user_id),
-    )
+        def add_permission(
+            permission_key: str,
+            group: typing.Optional[GroupData],
+            role: typing.Optional[RoleData],
+        ) -> None:
+            if permission_key not in permission_map:
+                permission_map[permission_key] = DetailedPermissionsData(
+                    permission_key=permission_key,
+                    is_directly_granted=bool(group is None and role is None),
+                    derived_from=PermissionDerivedFromData(),
+                )
+            if group:
+                permission_map[permission_key].derived_from.groups.append(group)
+            if role:
+                permission_map[permission_key].derived_from.roles.append(role)
path: api/permissions/permissions_calculator.py
user: login: gagantrivedi
body: Yeah, that is fixed in the rbac repo 
created_at: 2025-05-14T11:09:01Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-05-01T07:39:20Z
body: [vc]: #yr/XWmk38N7g0wriPf2pLkQVKo7Z8e3o9a2TWUd13aM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJkb2NzIiwicm9vdERpcmVjdG9yeSI6ImRvY3MiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9mbGFnc21pdGgvZG9jcy9KMUI2eU5ZZVF1VGdCV1VBN1NCZ3ZMNlVrOU1xIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQiLCJwcmV2aWV3VXJsIjoiZG9jcy1naXQtZmVhdC01MzA4LWZsYWdzbWl0aC52ZXJjZWwuYXBwIn0seyJuYW1lIjoiZmxhZ3NtaXRoLWZyb250ZW5kLXByZXZpZXciLCJyb290RGlyZWN0b3J5IjoiZnJvbnRlbmQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9mbGFnc21pdGgvZmxhZ3NtaXRoLWZyb250ZW5kLXByZXZpZXcvM1R2NzhGTHVuVVJvUm5EYlN6c2JoY1FiZGhieCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6ImZsYWdzbWl0aC1mcm9udGVuZC1wcmV2aWV3LWdpdC1mZWF0LTUzMDgtZmxhZ3NtaXRoLnZlcmNlbC5hcHAifSx7Im5hbWUiOiJmbGFnc21pdGgtZnJvbnRlbmQtc3RhZ2luZyIsInJvb3REaXJlY3RvcnkiOiJmcm9udGVuZCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2ZsYWdzbWl0aC9mbGFnc21pdGgtZnJvbnRlbmQtc3RhZ2luZy9xNjJWS1BQM3RZbUpTeWNtdVE0aXNoRnl4bkhLIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQiLCJwcmV2aWV3VXJsIjoiZmxhZ3NtaXRoLWZyb250ZW5kLXN0YWdpbmctZ2l0LWZlYXQtNTMwOC1mbGFnc21pdGgudmVyY2VsLmFwcCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>3 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **docs** | ⬜️ Ignored ([Inspect](https://vercel.com/flagsmith/docs/J1B6yNYeQuTgBWUA7SBgvL6Uk9Mq)) | [Visit Preview](https://docs-git-feat-5308-flagsmith.vercel.app) |  | May 14, 2025 11:08am |
| **flagsmith-frontend-preview** | ⬜️ Ignored ([Inspect](https://vercel.com/flagsmith/flagsmith-frontend-preview/3Tv78FLunURoRnDbSzsbhcQbdhbx)) | [Visit Preview](https://flagsmith-frontend-preview-git-feat-5308-flagsmith.vercel.app) |  | May 14, 2025 11:08am |
| **flagsmith-frontend-staging** | ⬜️ Ignored ([Inspect](https://vercel.com/flagsmith/flagsmith-frontend-staging/q62VKPP3tYmJSycmuQ4ishFyxnHK)) | [Visit Preview](https://flagsmith-frontend-staging-git-feat-5308-flagsmith.vercel.app) |  | May 14, 2025 11:08am |
</details>




author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-01T07:41:18Z
body: Uffizzi Preview `deployment-63330` was deleted.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-01T08:13:48Z
body: ## [Codecov](https://app.codecov.io/gh/Flagsmith/flagsmith/pull/5408?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Flagsmith) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 97.65%. Comparing base [(`8dc92c3`)](https://app.codecov.io/gh/Flagsmith/flagsmith/commit/8dc92c3ef9ede001377a3c8ee9a6d10794f08824?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Flagsmith) to head [(`05355e8`)](https://app.codecov.io/gh/Flagsmith/flagsmith/commit/05355e8bc1f544b501f23c2e39d1500bd4170584?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Flagsmith).
> Report is 42 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5408      +/-   ##
==========================================
+ Coverage   97.61%   97.65%   +0.03%     
==========================================
  Files        1237     1234       -3     
  Lines       42975    43299     +324     
==========================================
+ Hits        41952    42282     +330     
+ Misses       1023     1017       -6     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Flagsmith/flagsmith/pull/5408?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Flagsmith).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Flagsmith).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-12T01:50:22Z
body: #### Docker builds report

| Image | Build Status | Security report |
| ----- | ------------ | --------------- |
| `ghcr.io/flagsmith/flagsmith-api-test:pr-5408` | Finished :white_check_mark: | Skipped |
| `ghcr.io/flagsmith/flagsmith-api:pr-5408` | Finished :white_check_mark: | [Results](https://github.com/Flagsmith/flagsmith/security/code-scanning?query=is:open+tool:Trivy+pr:5408) :white_check_mark: |
| `ghcr.io/flagsmith/flagsmith-frontend:pr-5408` | Finished :white_check_mark: | [Results](https://github.com/Flagsmith/flagsmith/security/code-scanning?query=is:open+tool:Trivy+pr:5408) :white_check_mark: |
| `ghcr.io/flagsmith/flagsmith:pr-5408` | Finished :white_check_mark: | [Results](https://github.com/Flagsmith/flagsmith/security/code-scanning?query=is:open+tool:Trivy+pr:5408) :white_check_mark: |
| `ghcr.io/flagsmith/flagsmith-private-cloud:pr-5408` | Finished :white_check_mark: | [Results](https://github.com/Flagsmith/flagsmith/security/code-scanning?query=is:open+tool:Trivy+pr:5408) :white_check_mark: |
author_association: CONTRIBUTOR