Skip to content

Commit 67b6dcc

Browse files
committed
Add API action to manage object level permission on Products #386
Signed-off-by: tdruez <[email protected]>
1 parent 5bbf051 commit 67b6dcc

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

dje/api_permissions.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
10+
from django.contrib.auth import get_user_model
11+
from django.core.exceptions import ObjectDoesNotExist
12+
13+
from guardian.shortcuts import assign_perm
14+
from guardian.shortcuts import get_perms
15+
from guardian.shortcuts import get_user_perms
16+
from guardian.shortcuts import get_users_with_perms
17+
from guardian.shortcuts import remove_perm
18+
from rest_framework import permissions
19+
from rest_framework import serializers
20+
from rest_framework import status
21+
from rest_framework.decorators import action
22+
from rest_framework.response import Response
23+
24+
User = get_user_model()
25+
26+
27+
class CanManageObjectPermissions(permissions.BasePermission):
28+
"""
29+
Allows managing object-level permissions if the user is:
30+
- a superuser, or
31+
- the object's owner (configurable via ``owner_field`` on the View), or
32+
- has a special manage permission (global or object-level).
33+
"""
34+
35+
owner_field = "created_by"
36+
manage_permission_codename = "manage_object_permissions"
37+
38+
def has_object_permission(self, request, view, obj):
39+
user = request.user
40+
if not user.is_authenticated:
41+
return False
42+
43+
# 1. Superusers always allowed
44+
if user.is_superuser:
45+
return True
46+
47+
# 2. Check if user matches object's owner field
48+
# The field can be overridden on the ViewSet (e.g., owner_field = "owner")
49+
owner_field = getattr(view, "owner_field", self.owner_field)
50+
owner = getattr(obj, owner_field, None)
51+
if owner == user:
52+
return True
53+
54+
# 3. Check for specific manage permission (global or object-level)
55+
app_label = obj._meta.app_label
56+
codename = getattr(view, "manage_permission_codename", self.manage_permission_codename)
57+
perm_name = f"{app_label}.{codename}"
58+
if user.has_perm(perm_name) or user.has_perm(perm_name, obj):
59+
return True
60+
61+
return False
62+
63+
64+
class ObjectPermissionSerializer(serializers.Serializer):
65+
"""
66+
Generic serializer for representing or updating object-level permissions.
67+
Accepts:
68+
- user: user ID
69+
- permissions: list of permission codenames
70+
"""
71+
72+
# TODO: Scope by dataspace, see DataspacedSlugRelatedField
73+
user = serializers.SlugRelatedField(
74+
queryset=User.objects.all(),
75+
slug_field="username",
76+
)
77+
# user = DataspacedSlugRelatedField(slug_field="username")
78+
permissions = serializers.ListField(child=serializers.CharField(), allow_empty=False)
79+
80+
class Meta:
81+
fields = (
82+
"user",
83+
"permissions",
84+
)
85+
86+
def to_representation(self, instance):
87+
"""Make sure to provide the target object in context via `context["object"]`."""
88+
obj = self.context.get("object")
89+
user = instance
90+
return {
91+
"dataspace": user.dataspace.name,
92+
"username": user.get_username(),
93+
"object_permissions": get_user_perms(user, obj),
94+
"model_permissions": get_perms(user, obj),
95+
}
96+
97+
98+
class ObjectPermissionsMixin:
99+
"""
100+
Mixin that adds a `/permissions/` endpoint for any object-level ViewSet.
101+
Supports GET (list), POST (assign), and DELETE (remove) operations.
102+
103+
GET /api/{model}/{uuid}/permissions/ → list all users and perms
104+
POST /api/{model}/{uuid}/permissions/ → assign perms to a user
105+
DELETE /api/{model}/{uuid}/permissions/ → remove perms from a user
106+
"""
107+
108+
@action(
109+
detail=True,
110+
methods=["get", "post", "delete"],
111+
url_path="permissions",
112+
serializer_class=ObjectPermissionSerializer,
113+
permission_classes=[CanManageObjectPermissions],
114+
)
115+
def manage_permissions(self, request, *args, **kwargs):
116+
"""
117+
Manage object-level permissions for this object.
118+
119+
- GET: List users and their permissions.
120+
- POST: Assign permissions to a user. Provide `user` ID and `permissions` list.
121+
- DELETE: Remove permissions from a user. Provide `user` ID and `permissions`
122+
list.
123+
"""
124+
obj = self.get_object()
125+
serializer_context = {"object": obj}
126+
127+
if request.method == "GET":
128+
users_with_perms = get_users_with_perms(obj, attach_perms=True)
129+
serializer = self.get_serializer(
130+
users_with_perms.keys(), many=True, context=serializer_context
131+
)
132+
return Response(serializer.data, status=status.HTTP_200_OK)
133+
134+
# POST or DELETE
135+
serializer = self.get_serializer(data=request.data, context=serializer_context)
136+
if not serializer.is_valid():
137+
return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
138+
139+
user = serializer.validated_data["user"]
140+
perms = serializer.validated_data["permissions"]
141+
142+
if request.method == "POST":
143+
errors = []
144+
for perm in perms:
145+
try:
146+
assign_perm(perm, user, obj)
147+
except ObjectDoesNotExist as e:
148+
errors.append(f"Cannot assign permission '{perm}': {str(e)}")
149+
150+
if errors:
151+
return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST)
152+
153+
return Response({"status": "permissions assigned"}, status=status.HTTP_200_OK)
154+
155+
if request.method == "DELETE":
156+
errors = []
157+
for perm in perms:
158+
try:
159+
remove_perm(perm, user, obj)
160+
except ObjectDoesNotExist as e:
161+
errors.append(f"Cannot remove permission '{perm}': {str(e)}")
162+
163+
if errors:
164+
return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST)
165+
166+
return Response({"status": "permissions removed"}, status=status.HTTP_200_OK)

product_portfolio/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from dje.api import NameVersionHyperlinkedRelatedField
3232
from dje.api import ProductRelatedViewSet
3333
from dje.api import SPDXDocumentActionMixin
34+
from dje.api_permissions import ObjectPermissionsMixin
3435
from dje.filters import LastModifiedDateFilter
3536
from dje.filters import MultipleCharFilter
3637
from dje.filters import MultipleUUIDFilter
@@ -311,6 +312,7 @@ class Meta:
311312

312313

313314
class ProductViewSet(
315+
ObjectPermissionsMixin,
314316
SendAboutFilesMixin,
315317
AboutCodeFilesActionMixin,
316318
SPDXDocumentActionMixin,

0 commit comments

Comments
 (0)