Skip to content

Commit 1ddc619

Browse files
authored
feat: add API action to manage object level permission on Products (#395)
Signed-off-by: tdruez <tdruez@nexb.com> Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 1675194 commit 1ddc619

5 files changed

Lines changed: 467 additions & 10 deletions

File tree

dje/api.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -411,18 +411,19 @@ def get_queryset(self):
411411

412412
# Support for `many=True`
413413
serializer_field = self.parent if isinstance(self.parent, ManyRelatedField) else self
414-
415-
model_class = serializer_field.parent.Meta.model
416-
field_name = serializer_field.source
417-
field = model_class._meta.get_field(field_name)
418414
user = self.context["request"].user
419415

420-
if not queryset:
421-
manager = field.related_model.objects
422-
if is_secured(manager):
423-
queryset = manager.get_queryset(user=user)
424-
else:
425-
queryset = manager.all()
416+
if not queryset or self.scope_content_type:
417+
model_class = serializer_field.parent.Meta.model
418+
field_name = serializer_field.source
419+
field = model_class._meta.get_field(field_name)
420+
421+
if not queryset:
422+
manager = field.related_model.objects
423+
if is_secured(manager):
424+
queryset = manager.get_queryset(user=user)
425+
else:
426+
queryset = manager.all()
426427

427428
queryset = queryset.scope(user.dataspace)
428429

dje/api_permissions.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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.contrib.auth.models import Group
12+
from django.core.exceptions import ObjectDoesNotExist
13+
14+
from guardian.shortcuts import assign_perm
15+
from guardian.shortcuts import get_groups_with_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+
from dje.api import DataspacedSlugRelatedField
25+
26+
User = get_user_model()
27+
28+
29+
class CanManageObjectPermissions(permissions.BasePermission):
30+
"""
31+
Allows managing object-level permissions if the user is:
32+
- a superuser, or
33+
- the object's owner (configurable via ``owner_field`` on the View).
34+
"""
35+
36+
owner_field = "created_by"
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+
if user.is_superuser:
44+
return True
45+
46+
owner_field = getattr(view, "owner_field", self.owner_field)
47+
owner = getattr(obj, owner_field, None)
48+
return owner == user
49+
50+
51+
class ObjectPermissionSerializer(serializers.Serializer):
52+
"""
53+
Validates POST/DELETE input for the manage_permissions action.
54+
Exactly one of ``user`` or ``group`` must be provided alongside ``permissions``.
55+
"""
56+
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+
)
71+
permissions = serializers.ListField(child=serializers.CharField(), allow_empty=False)
72+
73+
class Meta:
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
86+
87+
88+
class ObjectPermissionsMixin:
89+
"""
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.
93+
94+
GET /api/{model}/{uuid}/permissions/
95+
POST /api/{model}/{uuid}/permissions/
96+
DELETE /api/{model}/{uuid}/permissions/
97+
"""
98+
99+
@action(
100+
detail=True,
101+
methods=["get", "post", "delete"],
102+
url_path="permissions",
103+
serializer_class=ObjectPermissionSerializer,
104+
permission_classes=[permissions.IsAuthenticated, CanManageObjectPermissions],
105+
)
106+
def manage_permissions(self, request, *args, **kwargs):
107+
"""
108+
Manage object-level permissions for this object.
109+
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.
113+
"""
114+
obj = self.get_object()
115+
serializer_context = {**self.get_serializer_context(), "object": obj}
116+
117+
if request.method == "GET":
118+
users_with_perms = get_users_with_perms(obj, attach_perms=True)
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)
138+
139+
# POST or DELETE
140+
serializer = self.get_serializer(data=request.data, context=serializer_context)
141+
if not serializer.is_valid():
142+
return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
143+
144+
target = serializer.validated_data["user"] or serializer.validated_data["group"]
145+
perms = serializer.validated_data["permissions"]
146+
147+
if request.method == "POST":
148+
errors = []
149+
for perm in perms:
150+
try:
151+
assign_perm(perm, target, obj)
152+
except ObjectDoesNotExist:
153+
errors.append(f"Cannot assign permission '{perm}' due to an internal error.")
154+
155+
if errors:
156+
return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST)
157+
158+
return Response({"status": "permissions assigned"}, status=status.HTTP_200_OK)
159+
160+
if request.method == "DELETE":
161+
errors = []
162+
for perm in perms:
163+
try:
164+
remove_perm(perm, target, obj)
165+
except ObjectDoesNotExist:
166+
errors.append(f"Cannot remove permission '{perm}' due to an internal error.")
167+
168+
if errors:
169+
return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST)
170+
171+
return Response({"status": "permissions removed"}, status=status.HTTP_200_OK)

docs/howto-5-product-object-permissions.rst

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,156 @@ examples and not recommendations.
9292

9393
You have now made the Product visible, and optionally editable, by DejaCode Users
9494
that are not superusers.
95+
96+
4. Manage Product Object Permissions via the REST API
97+
-----------------------------------------------------
98+
99+
Product object permissions can also be managed programmatically through the REST API.
100+
This is especially useful for CI/CD pipelines that create Product versions automatically
101+
and need to assign permissions without manual intervention.
102+
103+
The endpoint is available at::
104+
105+
/api/v2/products/{uuid}/permissions/
106+
107+
**Authentication**
108+
109+
All requests require authentication. The examples below use an API key passed via
110+
the ``Authorization`` header::
111+
112+
Authorization: Token <your-api-token>
113+
114+
**Available permissions**
115+
116+
The following permission codenames can be assigned to users or groups:
117+
118+
- ``view_product`` -- allows viewing the product
119+
- ``change_product`` -- allows editing the product
120+
- ``delete_product`` -- allows deleting the product
121+
122+
**Finding the Product UUID**
123+
124+
Retrieve the UUID from the product list endpoint::
125+
126+
GET /api/v2/products/?name=MyApp&version=2.0
127+
128+
The ``uuid`` field is included in each product entry of the response.
129+
130+
4.1 List current permissions
131+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
132+
133+
Retrieve all users and groups that currently have permissions on a product::
134+
135+
GET /api/v2/products/{uuid}/permissions/
136+
137+
Response::
138+
139+
{
140+
"users": [
141+
{
142+
"dataspace": "nexB",
143+
"username": "alice",
144+
"object_permissions": ["view_product", "change_product"]
145+
}
146+
],
147+
"groups": [
148+
{
149+
"name": "backend-team",
150+
"object_permissions": ["view_product"]
151+
}
152+
]
153+
}
154+
155+
4.2 Assign permissions to a user
156+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
157+
158+
Provide ``user`` (username) and a ``permissions`` list::
159+
160+
POST /api/v2/products/{uuid}/permissions/
161+
Content-Type: application/json
162+
163+
{
164+
"user": "alice",
165+
"permissions": ["view_product", "change_product"]
166+
}
167+
168+
Successful response::
169+
170+
{"status": "permissions assigned"}
171+
172+
4.3 Assign permissions to a group
173+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
174+
175+
Use ``group`` (group name) instead of ``user``. All members of the group will
176+
inherit the assigned permissions::
177+
178+
POST /api/v2/products/{uuid}/permissions/
179+
Content-Type: application/json
180+
181+
{
182+
"group": "backend-team",
183+
"permissions": ["view_product"]
184+
}
185+
186+
This is the recommended approach when multiple users need access to the same set
187+
of products. Manage group membership via the DejaCode admin, then assign the group
188+
to each product once.
189+
190+
4.4 Remove permissions from a user or group
191+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
192+
193+
Use the ``DELETE`` method with the same body format::
194+
195+
DELETE /api/v2/products/{uuid}/permissions/
196+
Content-Type: application/json
197+
198+
{
199+
"user": "alice",
200+
"permissions": ["change_product"]
201+
}
202+
203+
Or for a group::
204+
205+
DELETE /api/v2/products/{uuid}/permissions/
206+
Content-Type: application/json
207+
208+
{
209+
"group": "backend-team",
210+
"permissions": ["view_product"]
211+
}
212+
213+
Successful response::
214+
215+
{"status": "permissions removed"}
216+
217+
4.5 Automate permissions in a CI/CD pipeline
218+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
219+
220+
The following shell script illustrates how to create a Product version and immediately
221+
assign permissions to a group, so that team members can view it without any manual
222+
step::
223+
224+
BASE_URL="https://dejacode.example.com/api/v2"
225+
TOKEN="your-api-token"
226+
GROUP="backend-team"
227+
228+
# Create the product version
229+
RESPONSE=$(curl -s -X POST "$BASE_URL/products/" \
230+
-H "Authorization: Token $TOKEN" \
231+
-H "Content-Type: application/json" \
232+
-d '{"name": "MyApp", "version": "3.0"}')
233+
234+
UUID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['uuid'])")
235+
236+
# Assign view permission to the team
237+
curl -s -X POST "$BASE_URL/products/$UUID/permissions/" \
238+
-H "Authorization: Token $TOKEN" \
239+
-H "Content-Type: application/json" \
240+
-d "{\"group\": \"$GROUP\", \"permissions\": [\"view_product\"]}"
241+
242+
**Access control for the permissions endpoint**
243+
244+
Only the following users can call the ``/permissions/`` endpoint on a given product:
245+
246+
- A **superuser**
247+
- The user who **created** the product (``created_by`` field)

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
@@ -343,6 +344,7 @@ class Meta:
343344

344345

345346
class ProductViewSet(
347+
ObjectPermissionsMixin,
346348
SendAboutFilesMixin,
347349
AboutCodeFilesActionMixin,
348350
SPDXDocumentActionMixin,

0 commit comments

Comments
 (0)