Skip to content

Commit d53c301

Browse files
committed
add support for group
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent f199bda commit d53c301

2 files changed

Lines changed: 107 additions & 46 deletions

File tree

dje/api_permissions.py

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88

99

1010
from django.contrib.auth import get_user_model
11+
from django.contrib.auth.models import Group
1112
from django.core.exceptions import ObjectDoesNotExist
1213

1314
from guardian.shortcuts import assign_perm
14-
from guardian.shortcuts import get_perms
15-
from guardian.shortcuts import get_user_perms
15+
from guardian.shortcuts import get_groups_with_perms
1616
from guardian.shortcuts import get_users_with_perms
1717
from guardian.shortcuts import remove_perm
1818
from rest_framework import permissions
@@ -50,41 +50,50 @@ def has_object_permission(self, request, view, obj):
5050

5151
class ObjectPermissionSerializer(serializers.Serializer):
5252
"""
53-
Generic serializer for representing or updating object-level permissions.
54-
Accepts:
55-
- user: username
56-
- permissions: list of permission codenames
53+
Validates POST/DELETE input for the manage_permissions action.
54+
Exactly one of ``user`` or ``group`` must be provided alongside ``permissions``.
5755
"""
5856

59-
user = DataspacedSlugRelatedField(queryset=User.objects.all(), slug_field="username")
57+
user = DataspacedSlugRelatedField(
58+
queryset=User.objects.all(),
59+
slug_field="username",
60+
required=False,
61+
allow_null=True,
62+
default=None,
63+
)
64+
group = serializers.SlugRelatedField(
65+
queryset=Group.objects.all(),
66+
slug_field="name",
67+
required=False,
68+
allow_null=True,
69+
default=None,
70+
)
6071
permissions = serializers.ListField(child=serializers.CharField(), allow_empty=False)
6172

6273
class Meta:
63-
fields = (
64-
"user",
65-
"permissions",
66-
)
67-
68-
def to_representation(self, instance):
69-
"""Make sure to provide the target object in context via `context["object"]`."""
70-
obj = self.context.get("object")
71-
user = instance
72-
return {
73-
"dataspace": user.dataspace.name,
74-
"username": user.get_username(),
75-
"object_permissions": get_user_perms(user, obj),
76-
"model_permissions": get_perms(user, obj),
77-
}
74+
fields = ("user", "group", "permissions")
75+
76+
def validate(self, data):
77+
has_user = data.get("user") is not None
78+
has_group = data.get("group") is not None
79+
if not has_user and not has_group:
80+
raise serializers.ValidationError("Either 'user' or 'group' must be provided.")
81+
if has_user and has_group:
82+
raise serializers.ValidationError(
83+
"Only one of 'user' or 'group' can be provided, not both."
84+
)
85+
return data
7886

7987

8088
class ObjectPermissionsMixin:
8189
"""
82-
Mixin that adds a `/permissions/` endpoint for any object-level ViewSet.
83-
Supports GET (list), POST (assign), and DELETE (remove) operations.
90+
Mixin that adds a ``/permissions/`` endpoint for any object-level ViewSet.
91+
Supports GET (list), POST (assign), and DELETE (remove) operations for
92+
both individual users and groups.
8493
85-
GET /api/{model}/{uuid}/permissions/ list all users and perms
86-
POST /api/{model}/{uuid}/permissions/ assign perms to a user
87-
DELETE /api/{model}/{uuid}/permissions/ remove perms from a user
94+
GET /api/{model}/{uuid}/permissions/
95+
POST /api/{model}/{uuid}/permissions/
96+
DELETE /api/{model}/{uuid}/permissions/
8897
"""
8998

9099
@action(
@@ -98,33 +107,48 @@ def manage_permissions(self, request, *args, **kwargs):
98107
"""
99108
Manage object-level permissions for this object.
100109
101-
- GET: List users and their permissions.
102-
- POST: Assign permissions to a user. Provide `user` and `permissions` list.
103-
- DELETE: Remove permissions from a user. Provide `user` and `permissions` list.
110+
- GET: List users and groups with their permissions.
111+
- POST: Assign permissions. Provide ``user`` or ``group`` and ``permissions`` list.
112+
- DELETE: Remove permissions. Provide ``user`` or ``group`` and ``permissions`` list.
104113
"""
105114
obj = self.get_object()
106115
serializer_context = {**self.get_serializer_context(), "object": obj}
107116

108117
if request.method == "GET":
109118
users_with_perms = get_users_with_perms(obj, attach_perms=True)
110-
serializer = self.get_serializer(
111-
users_with_perms.keys(), many=True, context=serializer_context
112-
)
113-
return Response(serializer.data, status=status.HTTP_200_OK)
119+
groups_with_perms = get_groups_with_perms(obj, attach_perms=True)
120+
data = {
121+
"users": [
122+
{
123+
"dataspace": user.dataspace.name,
124+
"username": user.get_username(),
125+
"object_permissions": list(perms),
126+
}
127+
for user, perms in users_with_perms.items()
128+
],
129+
"groups": [
130+
{
131+
"name": group.name,
132+
"object_permissions": list(perms),
133+
}
134+
for group, perms in groups_with_perms.items()
135+
],
136+
}
137+
return Response(data, status=status.HTTP_200_OK)
114138

115139
# POST or DELETE
116140
serializer = self.get_serializer(data=request.data, context=serializer_context)
117141
if not serializer.is_valid():
118142
return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
119143

120-
user = serializer.validated_data["user"]
144+
target = serializer.validated_data["user"] or serializer.validated_data["group"]
121145
perms = serializer.validated_data["permissions"]
122146

123147
if request.method == "POST":
124148
errors = []
125149
for perm in perms:
126150
try:
127-
assign_perm(perm, user, obj)
151+
assign_perm(perm, target, obj)
128152
except ObjectDoesNotExist:
129153
errors.append(f"Cannot assign permission '{perm}' due to an internal error.")
130154

@@ -137,7 +161,7 @@ def manage_permissions(self, request, *args, **kwargs):
137161
errors = []
138162
for perm in perms:
139163
try:
140-
remove_perm(perm, user, obj)
164+
remove_perm(perm, target, obj)
141165
except ObjectDoesNotExist:
142166
errors.append(f"Cannot remove permission '{perm}' due to an internal error.")
143167

product_portfolio/tests/test_api.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,8 @@ def test_api_product_endpoint_cyclonedx_sbom_action(self):
587587
self.assertEqual("Spec version 10.10 not supported", response.data)
588588

589589
def test_api_product_endpoint_manage_permissions_action(self):
590+
from django.contrib.auth.models import Group
591+
590592
url = reverse("api_v2:product-manage-permissions", args=[self.product1.uuid])
591593

592594
# Unauthenticated access is rejected with 403
@@ -604,17 +606,20 @@ def test_api_product_endpoint_manage_permissions_action(self):
604606
response = self.client.get(url)
605607
self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
606608

607-
# Superuser can GET the permissions list
609+
# Superuser GET: response has "users" and "groups" keys
608610
self.client.login(username=self.super_user.username, password="secret")
609611
response = self.client.get(url)
610612
self.assertEqual(status.HTTP_200_OK, response.status_code)
613+
self.assertIn("users", response.data)
614+
self.assertIn("groups", response.data)
611615
# base_user has view_product at this point
612-
self.assertEqual(1, len(response.data))
613-
self.assertEqual(self.base_user.username, response.data[0]["username"])
614-
self.assertEqual(self.dataspace.name, response.data[0]["dataspace"])
615-
self.assertIn("view_product", response.data[0]["object_permissions"])
616+
self.assertEqual(1, len(response.data["users"]))
617+
self.assertEqual(self.base_user.username, response.data["users"][0]["username"])
618+
self.assertEqual(self.dataspace.name, response.data["users"][0]["dataspace"])
619+
self.assertIn("view_product", response.data["users"][0]["object_permissions"])
620+
self.assertEqual([], response.data["groups"])
616621

617-
# Superuser can POST to assign multiple permissions at once
622+
# Superuser can POST to assign multiple permissions to a user at once
618623
data = {
619624
"user": self.admin_user.username,
620625
"permissions": ["view_product", "change_product"],
@@ -625,14 +630,36 @@ def test_api_product_endpoint_manage_permissions_action(self):
625630
self.assertIn("view_product", get_perms(self.admin_user, self.product1))
626631
self.assertIn("change_product", get_perms(self.admin_user, self.product1))
627632

628-
# Superuser can DELETE to remove permissions
633+
# Superuser can DELETE to remove user permissions
629634
data = {"user": self.admin_user.username, "permissions": ["view_product", "change_product"]}
630635
response = self.client.delete(url, data, content_type="application/json")
631636
self.assertEqual(status.HTTP_200_OK, response.status_code)
632637
self.assertEqual({"status": "permissions removed"}, response.data)
633638
self.assertNotIn("view_product", get_perms(self.admin_user, self.product1))
634639
self.assertNotIn("change_product", get_perms(self.admin_user, self.product1))
635640

641+
# Superuser can POST to assign permissions to a group
642+
team = Group.objects.create(name="backend-team")
643+
data = {"group": team.name, "permissions": ["view_product", "change_product"]}
644+
response = self.client.post(url, data, format="json")
645+
self.assertEqual(status.HTTP_200_OK, response.status_code)
646+
self.assertEqual({"status": "permissions assigned"}, response.data)
647+
self.assertIn("view_product", get_perms(team, self.product1))
648+
self.assertIn("change_product", get_perms(team, self.product1))
649+
650+
# GET lists the group with its permissions
651+
response = self.client.get(url)
652+
self.assertEqual(status.HTTP_200_OK, response.status_code)
653+
self.assertEqual(1, len(response.data["groups"]))
654+
self.assertEqual(team.name, response.data["groups"][0]["name"])
655+
self.assertIn("view_product", response.data["groups"][0]["object_permissions"])
656+
657+
# Superuser can DELETE to remove group permissions
658+
data = {"group": team.name, "permissions": ["view_product", "change_product"]}
659+
response = self.client.delete(url, data, content_type="application/json")
660+
self.assertEqual(status.HTTP_200_OK, response.status_code)
661+
self.assertNotIn("view_product", get_perms(team, self.product1))
662+
636663
# Product creator (created_by) can GET, POST, and DELETE
637664
self.product1.created_by = self.admin_user
638665
self.product1.save()
@@ -661,8 +688,18 @@ def test_api_product_endpoint_manage_permissions_action(self):
661688
response = self.client.delete(url, data, content_type="application/json")
662689
self.assertEqual(status.HTTP_200_OK, response.status_code)
663690

664-
# Missing required fields returns 400
665-
response = self.client.post(url, {}, format="json")
691+
# Neither user nor group returns 400
692+
response = self.client.post(url, {"permissions": ["view_product"]}, format="json")
693+
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
694+
self.assertIn("errors", response.data)
695+
696+
# Both user and group returns 400
697+
data = {
698+
"user": self.base_user.username,
699+
"group": team.name,
700+
"permissions": ["view_product"],
701+
}
702+
response = self.client.post(url, data, format="json")
666703
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
667704
self.assertIn("errors", response.data)
668705

0 commit comments

Comments
 (0)