From dc0555c0ded93088e519c6de6ff7df6764139709 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 21 Oct 2025 19:33:12 +0400 Subject: [PATCH 1/6] Add API action to manage object level permission on Products #386 Signed-off-by: tdruez --- dje/api_permissions.py | 166 +++++++++++++++++++++++++++++++++++++++ product_portfolio/api.py | 2 + 2 files changed, 168 insertions(+) create mode 100644 dje/api_permissions.py diff --git a/dje/api_permissions.py b/dje/api_permissions.py new file mode 100644 index 00000000..0626a978 --- /dev/null +++ b/dje/api_permissions.py @@ -0,0 +1,166 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist + +from guardian.shortcuts import assign_perm +from guardian.shortcuts import get_perms +from guardian.shortcuts import get_user_perms +from guardian.shortcuts import get_users_with_perms +from guardian.shortcuts import remove_perm +from rest_framework import permissions +from rest_framework import serializers +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response + +User = get_user_model() + + +class CanManageObjectPermissions(permissions.BasePermission): + """ + Allows managing object-level permissions if the user is: + - a superuser, or + - the object's owner (configurable via ``owner_field`` on the View), or + - has a special manage permission (global or object-level). + """ + + owner_field = "created_by" + manage_permission_codename = "manage_object_permissions" + + def has_object_permission(self, request, view, obj): + user = request.user + if not user.is_authenticated: + return False + + # 1. Superusers always allowed + if user.is_superuser: + return True + + # 2. Check if user matches object's owner field + # The field can be overridden on the ViewSet (e.g., owner_field = "owner") + owner_field = getattr(view, "owner_field", self.owner_field) + owner = getattr(obj, owner_field, None) + if owner == user: + return True + + # 3. Check for specific manage permission (global or object-level) + app_label = obj._meta.app_label + codename = getattr(view, "manage_permission_codename", self.manage_permission_codename) + perm_name = f"{app_label}.{codename}" + if user.has_perm(perm_name) or user.has_perm(perm_name, obj): + return True + + return False + + +class ObjectPermissionSerializer(serializers.Serializer): + """ + Generic serializer for representing or updating object-level permissions. + Accepts: + - user: user ID + - permissions: list of permission codenames + """ + + # TODO: Scope by dataspace, see DataspacedSlugRelatedField + user = serializers.SlugRelatedField( + queryset=User.objects.all(), + slug_field="username", + ) + # user = DataspacedSlugRelatedField(slug_field="username") + permissions = serializers.ListField(child=serializers.CharField(), allow_empty=False) + + class Meta: + fields = ( + "user", + "permissions", + ) + + def to_representation(self, instance): + """Make sure to provide the target object in context via `context["object"]`.""" + obj = self.context.get("object") + user = instance + return { + "dataspace": user.dataspace.name, + "username": user.get_username(), + "object_permissions": get_user_perms(user, obj), + "model_permissions": get_perms(user, obj), + } + + +class ObjectPermissionsMixin: + """ + Mixin that adds a `/permissions/` endpoint for any object-level ViewSet. + Supports GET (list), POST (assign), and DELETE (remove) operations. + + GET /api/{model}/{uuid}/permissions/ → list all users and perms + POST /api/{model}/{uuid}/permissions/ → assign perms to a user + DELETE /api/{model}/{uuid}/permissions/ → remove perms from a user + """ + + @action( + detail=True, + methods=["get", "post", "delete"], + url_path="permissions", + serializer_class=ObjectPermissionSerializer, + permission_classes=[CanManageObjectPermissions], + ) + def manage_permissions(self, request, *args, **kwargs): + """ + Manage object-level permissions for this object. + + - GET: List users and their permissions. + - POST: Assign permissions to a user. Provide `user` ID and `permissions` list. + - DELETE: Remove permissions from a user. Provide `user` ID and `permissions` + list. + """ + obj = self.get_object() + serializer_context = {"object": obj} + + if request.method == "GET": + users_with_perms = get_users_with_perms(obj, attach_perms=True) + serializer = self.get_serializer( + users_with_perms.keys(), many=True, context=serializer_context + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # POST or DELETE + serializer = self.get_serializer(data=request.data, context=serializer_context) + if not serializer.is_valid(): + return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + user = serializer.validated_data["user"] + perms = serializer.validated_data["permissions"] + + if request.method == "POST": + errors = [] + for perm in perms: + try: + assign_perm(perm, user, obj) + except ObjectDoesNotExist as e: + errors.append(f"Cannot assign permission '{perm}': {str(e)}") + + if errors: + return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"status": "permissions assigned"}, status=status.HTTP_200_OK) + + if request.method == "DELETE": + errors = [] + for perm in perms: + try: + remove_perm(perm, user, obj) + except ObjectDoesNotExist as e: + errors.append(f"Cannot remove permission '{perm}': {str(e)}") + + if errors: + return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"status": "permissions removed"}, status=status.HTTP_200_OK) diff --git a/product_portfolio/api.py b/product_portfolio/api.py index c1ea0ea6..c8d9998f 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -31,6 +31,7 @@ from dje.api import NameVersionHyperlinkedRelatedField from dje.api import ProductRelatedViewSet from dje.api import SPDXDocumentActionMixin +from dje.api_permissions import ObjectPermissionsMixin from dje.filters import LastModifiedDateFilter from dje.filters import MultipleCharFilter from dje.filters import MultipleUUIDFilter @@ -343,6 +344,7 @@ class Meta: class ProductViewSet( + ObjectPermissionsMixin, SendAboutFilesMixin, AboutCodeFilesActionMixin, SPDXDocumentActionMixin, From 12f1205fb401c4dabc948dee6d7de3fbbf2a459f Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 29 Jun 2026 11:44:23 +0400 Subject: [PATCH 2/6] refine code and add test Signed-off-by: tdruez --- dje/api_permissions.py | 26 +++--------- product_portfolio/tests/test_api.py | 64 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/dje/api_permissions.py b/dje/api_permissions.py index 0626a978..8a365fa5 100644 --- a/dje/api_permissions.py +++ b/dje/api_permissions.py @@ -6,7 +6,6 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # - from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist @@ -28,37 +27,22 @@ class CanManageObjectPermissions(permissions.BasePermission): """ Allows managing object-level permissions if the user is: - a superuser, or - - the object's owner (configurable via ``owner_field`` on the View), or - - has a special manage permission (global or object-level). + - the object's owner (configurable via ``owner_field`` on the View). """ owner_field = "created_by" - manage_permission_codename = "manage_object_permissions" def has_object_permission(self, request, view, obj): user = request.user if not user.is_authenticated: return False - # 1. Superusers always allowed if user.is_superuser: return True - # 2. Check if user matches object's owner field - # The field can be overridden on the ViewSet (e.g., owner_field = "owner") owner_field = getattr(view, "owner_field", self.owner_field) owner = getattr(obj, owner_field, None) - if owner == user: - return True - - # 3. Check for specific manage permission (global or object-level) - app_label = obj._meta.app_label - codename = getattr(view, "manage_permission_codename", self.manage_permission_codename) - perm_name = f"{app_label}.{codename}" - if user.has_perm(perm_name) or user.has_perm(perm_name, obj): - return True - - return False + return owner == user class ObjectPermissionSerializer(serializers.Serializer): @@ -100,9 +84,9 @@ class ObjectPermissionsMixin: Mixin that adds a `/permissions/` endpoint for any object-level ViewSet. Supports GET (list), POST (assign), and DELETE (remove) operations. - GET /api/{model}/{uuid}/permissions/ → list all users and perms - POST /api/{model}/{uuid}/permissions/ → assign perms to a user - DELETE /api/{model}/{uuid}/permissions/ → remove perms from a user + GET /api/{model}/{uuid}/permissions/ list all users and perms + POST /api/{model}/{uuid}/permissions/ assign perms to a user + DELETE /api/{model}/{uuid}/permissions/ remove perms from a user """ @action( diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 0afaee9e..997e2c5d 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -586,6 +586,70 @@ def test_api_product_endpoint_cyclonedx_sbom_action(self): self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) self.assertEqual("Spec version 10.10 not supported", response.data) + def test_api_product_endpoint_manage_permissions_action(self): + url = reverse("api_v2:product-manage-permissions", args=[self.product1.uuid]) + + # User without view_product gets 404 (object not in secured queryset) + self.client.login(username=self.base_user.username, password="secret") + response = self.client.get(url) + self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) + + # User with view_product but not owner gets 403 + assign_perm("view_product", self.base_user, self.product1) + response = self.client.get(url) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + + # Superuser can GET the permissions list + self.client.login(username=self.super_user.username, password="secret") + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + # base_user has view_product at this point + self.assertEqual(1, len(response.data)) + self.assertEqual(self.base_user.username, response.data[0]["username"]) + self.assertIn("view_product", response.data[0]["object_permissions"]) + + # Superuser can POST to assign permissions + data = {"user": self.admin_user.username, "permissions": ["view_product"]} + response = self.client.post(url, data, format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual({"status": "permissions assigned"}, response.data) + self.assertIn("view_product", get_perms(self.admin_user, self.product1)) + + # Superuser can DELETE to remove permissions + data = {"user": self.admin_user.username, "permissions": ["view_product"]} + response = self.client.delete(url, data, content_type="application/json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual({"status": "permissions removed"}, response.data) + self.assertNotIn("view_product", get_perms(self.admin_user, self.product1)) + + # Product creator (created_by) can manage permissions + self.product1.created_by = self.admin_user + self.product1.save() + assign_perm("view_product", self.admin_user, self.product1) + self.client.login(username=self.admin_user.username, password="secret") + data = {"user": self.base_user.username, "permissions": ["change_product"]} + response = self.client.post(url, data, format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertIn("change_product", get_perms(self.base_user, self.product1)) + + # Invalid permission codename returns 400 + self.client.login(username=self.super_user.username, password="secret") + data = {"user": self.base_user.username, "permissions": ["nonexistent_perm"]} + response = self.client.post(url, data, format="json") + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertIn("errors", response.data) + + # Missing required fields returns 400 + response = self.client.post(url, {}, format="json") + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertIn("errors", response.data) + + # Empty permissions list returns 400 + data = {"user": self.base_user.username, "permissions": []} + response = self.client.post(url, data, format="json") + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertIn("errors", response.data) + class ProductRelatedAPITestCase(TestCase): def setUp(self): From 1d95465f7fdd9fec66df43d2c0dd9e342b5c1e78 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 29 Jun 2026 12:03:35 +0400 Subject: [PATCH 3/6] refine the code Signed-off-by: tdruez --- dje/api.py | 21 +++++++++++---------- dje/api_permissions.py | 27 ++++++++++++--------------- product_portfolio/tests/test_api.py | 7 +++++++ 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/dje/api.py b/dje/api.py index 7a4bc0bf..c6a4b948 100644 --- a/dje/api.py +++ b/dje/api.py @@ -411,18 +411,19 @@ def get_queryset(self): # Support for `many=True` serializer_field = self.parent if isinstance(self.parent, ManyRelatedField) else self - - model_class = serializer_field.parent.Meta.model - field_name = serializer_field.source - field = model_class._meta.get_field(field_name) user = self.context["request"].user - if not queryset: - manager = field.related_model.objects - if is_secured(manager): - queryset = manager.get_queryset(user=user) - else: - queryset = manager.all() + if not queryset or self.scope_content_type: + model_class = serializer_field.parent.Meta.model + field_name = serializer_field.source + field = model_class._meta.get_field(field_name) + + if not queryset: + manager = field.related_model.objects + if is_secured(manager): + queryset = manager.get_queryset(user=user) + else: + queryset = manager.all() queryset = queryset.scope(user.dataspace) diff --git a/dje/api_permissions.py b/dje/api_permissions.py index 8a365fa5..be67cd1d 100644 --- a/dje/api_permissions.py +++ b/dje/api_permissions.py @@ -6,6 +6,7 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # + from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist @@ -20,6 +21,8 @@ from rest_framework.decorators import action from rest_framework.response import Response +from dje.api import DataspacedSlugRelatedField + User = get_user_model() @@ -49,16 +52,11 @@ class ObjectPermissionSerializer(serializers.Serializer): """ Generic serializer for representing or updating object-level permissions. Accepts: - - user: user ID + - user: username - permissions: list of permission codenames """ - # TODO: Scope by dataspace, see DataspacedSlugRelatedField - user = serializers.SlugRelatedField( - queryset=User.objects.all(), - slug_field="username", - ) - # user = DataspacedSlugRelatedField(slug_field="username") + user = DataspacedSlugRelatedField(queryset=User.objects.all(), slug_field="username") permissions = serializers.ListField(child=serializers.CharField(), allow_empty=False) class Meta: @@ -101,12 +99,11 @@ def manage_permissions(self, request, *args, **kwargs): Manage object-level permissions for this object. - GET: List users and their permissions. - - POST: Assign permissions to a user. Provide `user` ID and `permissions` list. - - DELETE: Remove permissions from a user. Provide `user` ID and `permissions` - list. + - POST: Assign permissions to a user. Provide `user` and `permissions` list. + - DELETE: Remove permissions from a user. Provide `user` and `permissions` list. """ obj = self.get_object() - serializer_context = {"object": obj} + serializer_context = {**self.get_serializer_context(), "object": obj} if request.method == "GET": users_with_perms = get_users_with_perms(obj, attach_perms=True) @@ -128,8 +125,8 @@ def manage_permissions(self, request, *args, **kwargs): for perm in perms: try: assign_perm(perm, user, obj) - except ObjectDoesNotExist as e: - errors.append(f"Cannot assign permission '{perm}': {str(e)}") + except ObjectDoesNotExist: + errors.append(f"Cannot assign permission '{perm}' due to an internal error.") if errors: return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST) @@ -141,8 +138,8 @@ def manage_permissions(self, request, *args, **kwargs): for perm in perms: try: remove_perm(perm, user, obj) - except ObjectDoesNotExist as e: - errors.append(f"Cannot remove permission '{perm}': {str(e)}") + except ObjectDoesNotExist: + errors.append(f"Cannot assign permission '{perm}' due to an internal error.") if errors: return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST) diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 997e2c5d..3fe5e1a5 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -650,6 +650,13 @@ def test_api_product_endpoint_manage_permissions_action(self): self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) self.assertIn("errors", response.data) + # User from another dataspace is rejected (dataspace scoping) + other_dataspace_user = create_user("other_ds_user", self.alternate_dataspace) + data = {"user": other_dataspace_user.username, "permissions": ["view_product"]} + response = self.client.post(url, data, format="json") + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertIn("errors", response.data) + class ProductRelatedAPITestCase(TestCase): def setUp(self): From f199bda0765b176afd7019d1a93a782a1b395659 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 29 Jun 2026 12:25:20 +0400 Subject: [PATCH 4/6] refine code and add tests Signed-off-by: tdruez --- dje/api_permissions.py | 4 ++-- product_portfolio/tests/test_api.py | 32 ++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/dje/api_permissions.py b/dje/api_permissions.py index be67cd1d..e3949743 100644 --- a/dje/api_permissions.py +++ b/dje/api_permissions.py @@ -92,7 +92,7 @@ class ObjectPermissionsMixin: methods=["get", "post", "delete"], url_path="permissions", serializer_class=ObjectPermissionSerializer, - permission_classes=[CanManageObjectPermissions], + permission_classes=[permissions.IsAuthenticated, CanManageObjectPermissions], ) def manage_permissions(self, request, *args, **kwargs): """ @@ -139,7 +139,7 @@ def manage_permissions(self, request, *args, **kwargs): try: remove_perm(perm, user, obj) except ObjectDoesNotExist: - errors.append(f"Cannot assign permission '{perm}' due to an internal error.") + errors.append(f"Cannot remove permission '{perm}' due to an internal error.") if errors: return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST) diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 3fe5e1a5..0ba88e49 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -589,6 +589,11 @@ def test_api_product_endpoint_cyclonedx_sbom_action(self): def test_api_product_endpoint_manage_permissions_action(self): url = reverse("api_v2:product-manage-permissions", args=[self.product1.uuid]) + # Unauthenticated access is rejected with 403 + self.client.logout() + response = self.client.get(url) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + # User without view_product gets 404 (object not in secured queryset) self.client.login(username=self.base_user.username, password="secret") response = self.client.get(url) @@ -606,38 +611,55 @@ def test_api_product_endpoint_manage_permissions_action(self): # base_user has view_product at this point self.assertEqual(1, len(response.data)) self.assertEqual(self.base_user.username, response.data[0]["username"]) + self.assertEqual(self.dataspace.name, response.data[0]["dataspace"]) self.assertIn("view_product", response.data[0]["object_permissions"]) - # Superuser can POST to assign permissions - data = {"user": self.admin_user.username, "permissions": ["view_product"]} + # Superuser can POST to assign multiple permissions at once + data = { + "user": self.admin_user.username, + "permissions": ["view_product", "change_product"], + } response = self.client.post(url, data, format="json") self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertEqual({"status": "permissions assigned"}, response.data) self.assertIn("view_product", get_perms(self.admin_user, self.product1)) + self.assertIn("change_product", get_perms(self.admin_user, self.product1)) # Superuser can DELETE to remove permissions - data = {"user": self.admin_user.username, "permissions": ["view_product"]} + data = {"user": self.admin_user.username, "permissions": ["view_product", "change_product"]} response = self.client.delete(url, data, content_type="application/json") self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertEqual({"status": "permissions removed"}, response.data) self.assertNotIn("view_product", get_perms(self.admin_user, self.product1)) + self.assertNotIn("change_product", get_perms(self.admin_user, self.product1)) - # Product creator (created_by) can manage permissions + # Product creator (created_by) can GET, POST, and DELETE self.product1.created_by = self.admin_user self.product1.save() assign_perm("view_product", self.admin_user, self.product1) self.client.login(username=self.admin_user.username, password="secret") + + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + data = {"user": self.base_user.username, "permissions": ["change_product"]} response = self.client.post(url, data, format="json") self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertIn("change_product", get_perms(self.base_user, self.product1)) - # Invalid permission codename returns 400 + response = self.client.delete(url, data, content_type="application/json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertNotIn("change_product", get_perms(self.base_user, self.product1)) + + # Invalid permission codename on POST returns 400 self.client.login(username=self.super_user.username, password="secret") data = {"user": self.base_user.username, "permissions": ["nonexistent_perm"]} response = self.client.post(url, data, format="json") self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) self.assertIn("errors", response.data) + # DELETE with unknown codename is a no-op (guardian remove_perm is idempotent) + response = self.client.delete(url, data, content_type="application/json") + self.assertEqual(status.HTTP_200_OK, response.status_code) # Missing required fields returns 400 response = self.client.post(url, {}, format="json") From d53c3015b789e9f52a5cd9aaa413673f9387553e Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 29 Jun 2026 12:40:53 +0400 Subject: [PATCH 5/6] add support for group Signed-off-by: tdruez --- dje/api_permissions.py | 98 ++++++++++++++++++----------- product_portfolio/tests/test_api.py | 55 +++++++++++++--- 2 files changed, 107 insertions(+), 46 deletions(-) diff --git a/dje/api_permissions.py b/dje/api_permissions.py index e3949743..5254b6a2 100644 --- a/dje/api_permissions.py +++ b/dje/api_permissions.py @@ -8,11 +8,11 @@ from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.exceptions import ObjectDoesNotExist from guardian.shortcuts import assign_perm -from guardian.shortcuts import get_perms -from guardian.shortcuts import get_user_perms +from guardian.shortcuts import get_groups_with_perms from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import remove_perm from rest_framework import permissions @@ -50,41 +50,50 @@ def has_object_permission(self, request, view, obj): class ObjectPermissionSerializer(serializers.Serializer): """ - Generic serializer for representing or updating object-level permissions. - Accepts: - - user: username - - permissions: list of permission codenames + Validates POST/DELETE input for the manage_permissions action. + Exactly one of ``user`` or ``group`` must be provided alongside ``permissions``. """ - user = DataspacedSlugRelatedField(queryset=User.objects.all(), slug_field="username") + user = DataspacedSlugRelatedField( + queryset=User.objects.all(), + slug_field="username", + required=False, + allow_null=True, + default=None, + ) + group = serializers.SlugRelatedField( + queryset=Group.objects.all(), + slug_field="name", + required=False, + allow_null=True, + default=None, + ) permissions = serializers.ListField(child=serializers.CharField(), allow_empty=False) class Meta: - fields = ( - "user", - "permissions", - ) - - def to_representation(self, instance): - """Make sure to provide the target object in context via `context["object"]`.""" - obj = self.context.get("object") - user = instance - return { - "dataspace": user.dataspace.name, - "username": user.get_username(), - "object_permissions": get_user_perms(user, obj), - "model_permissions": get_perms(user, obj), - } + fields = ("user", "group", "permissions") + + def validate(self, data): + has_user = data.get("user") is not None + has_group = data.get("group") is not None + if not has_user and not has_group: + raise serializers.ValidationError("Either 'user' or 'group' must be provided.") + if has_user and has_group: + raise serializers.ValidationError( + "Only one of 'user' or 'group' can be provided, not both." + ) + return data class ObjectPermissionsMixin: """ - Mixin that adds a `/permissions/` endpoint for any object-level ViewSet. - Supports GET (list), POST (assign), and DELETE (remove) operations. + Mixin that adds a ``/permissions/`` endpoint for any object-level ViewSet. + Supports GET (list), POST (assign), and DELETE (remove) operations for + both individual users and groups. - GET /api/{model}/{uuid}/permissions/ list all users and perms - POST /api/{model}/{uuid}/permissions/ assign perms to a user - DELETE /api/{model}/{uuid}/permissions/ remove perms from a user + GET /api/{model}/{uuid}/permissions/ + POST /api/{model}/{uuid}/permissions/ + DELETE /api/{model}/{uuid}/permissions/ """ @action( @@ -98,33 +107,48 @@ def manage_permissions(self, request, *args, **kwargs): """ Manage object-level permissions for this object. - - GET: List users and their permissions. - - POST: Assign permissions to a user. Provide `user` and `permissions` list. - - DELETE: Remove permissions from a user. Provide `user` and `permissions` list. + - GET: List users and groups with their permissions. + - POST: Assign permissions. Provide ``user`` or ``group`` and ``permissions`` list. + - DELETE: Remove permissions. Provide ``user`` or ``group`` and ``permissions`` list. """ obj = self.get_object() serializer_context = {**self.get_serializer_context(), "object": obj} if request.method == "GET": users_with_perms = get_users_with_perms(obj, attach_perms=True) - serializer = self.get_serializer( - users_with_perms.keys(), many=True, context=serializer_context - ) - return Response(serializer.data, status=status.HTTP_200_OK) + groups_with_perms = get_groups_with_perms(obj, attach_perms=True) + data = { + "users": [ + { + "dataspace": user.dataspace.name, + "username": user.get_username(), + "object_permissions": list(perms), + } + for user, perms in users_with_perms.items() + ], + "groups": [ + { + "name": group.name, + "object_permissions": list(perms), + } + for group, perms in groups_with_perms.items() + ], + } + return Response(data, status=status.HTTP_200_OK) # POST or DELETE serializer = self.get_serializer(data=request.data, context=serializer_context) if not serializer.is_valid(): return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) - user = serializer.validated_data["user"] + target = serializer.validated_data["user"] or serializer.validated_data["group"] perms = serializer.validated_data["permissions"] if request.method == "POST": errors = [] for perm in perms: try: - assign_perm(perm, user, obj) + assign_perm(perm, target, obj) except ObjectDoesNotExist: errors.append(f"Cannot assign permission '{perm}' due to an internal error.") @@ -137,7 +161,7 @@ def manage_permissions(self, request, *args, **kwargs): errors = [] for perm in perms: try: - remove_perm(perm, user, obj) + remove_perm(perm, target, obj) except ObjectDoesNotExist: errors.append(f"Cannot remove permission '{perm}' due to an internal error.") diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 0ba88e49..ce43a34e 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -587,6 +587,8 @@ def test_api_product_endpoint_cyclonedx_sbom_action(self): self.assertEqual("Spec version 10.10 not supported", response.data) def test_api_product_endpoint_manage_permissions_action(self): + from django.contrib.auth.models import Group + url = reverse("api_v2:product-manage-permissions", args=[self.product1.uuid]) # Unauthenticated access is rejected with 403 @@ -604,17 +606,20 @@ def test_api_product_endpoint_manage_permissions_action(self): response = self.client.get(url) self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) - # Superuser can GET the permissions list + # Superuser GET: response has "users" and "groups" keys self.client.login(username=self.super_user.username, password="secret") response = self.client.get(url) self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertIn("users", response.data) + self.assertIn("groups", response.data) # base_user has view_product at this point - self.assertEqual(1, len(response.data)) - self.assertEqual(self.base_user.username, response.data[0]["username"]) - self.assertEqual(self.dataspace.name, response.data[0]["dataspace"]) - self.assertIn("view_product", response.data[0]["object_permissions"]) + self.assertEqual(1, len(response.data["users"])) + self.assertEqual(self.base_user.username, response.data["users"][0]["username"]) + self.assertEqual(self.dataspace.name, response.data["users"][0]["dataspace"]) + self.assertIn("view_product", response.data["users"][0]["object_permissions"]) + self.assertEqual([], response.data["groups"]) - # Superuser can POST to assign multiple permissions at once + # Superuser can POST to assign multiple permissions to a user at once data = { "user": self.admin_user.username, "permissions": ["view_product", "change_product"], @@ -625,7 +630,7 @@ def test_api_product_endpoint_manage_permissions_action(self): self.assertIn("view_product", get_perms(self.admin_user, self.product1)) self.assertIn("change_product", get_perms(self.admin_user, self.product1)) - # Superuser can DELETE to remove permissions + # Superuser can DELETE to remove user permissions data = {"user": self.admin_user.username, "permissions": ["view_product", "change_product"]} response = self.client.delete(url, data, content_type="application/json") self.assertEqual(status.HTTP_200_OK, response.status_code) @@ -633,6 +638,28 @@ def test_api_product_endpoint_manage_permissions_action(self): self.assertNotIn("view_product", get_perms(self.admin_user, self.product1)) self.assertNotIn("change_product", get_perms(self.admin_user, self.product1)) + # Superuser can POST to assign permissions to a group + team = Group.objects.create(name="backend-team") + data = {"group": team.name, "permissions": ["view_product", "change_product"]} + response = self.client.post(url, data, format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual({"status": "permissions assigned"}, response.data) + self.assertIn("view_product", get_perms(team, self.product1)) + self.assertIn("change_product", get_perms(team, self.product1)) + + # GET lists the group with its permissions + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(1, len(response.data["groups"])) + self.assertEqual(team.name, response.data["groups"][0]["name"]) + self.assertIn("view_product", response.data["groups"][0]["object_permissions"]) + + # Superuser can DELETE to remove group permissions + data = {"group": team.name, "permissions": ["view_product", "change_product"]} + response = self.client.delete(url, data, content_type="application/json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertNotIn("view_product", get_perms(team, self.product1)) + # Product creator (created_by) can GET, POST, and DELETE self.product1.created_by = self.admin_user self.product1.save() @@ -661,8 +688,18 @@ def test_api_product_endpoint_manage_permissions_action(self): response = self.client.delete(url, data, content_type="application/json") self.assertEqual(status.HTTP_200_OK, response.status_code) - # Missing required fields returns 400 - response = self.client.post(url, {}, format="json") + # Neither user nor group returns 400 + response = self.client.post(url, {"permissions": ["view_product"]}, format="json") + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertIn("errors", response.data) + + # Both user and group returns 400 + data = { + "user": self.base_user.username, + "group": team.name, + "permissions": ["view_product"], + } + response = self.client.post(url, data, format="json") self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) self.assertIn("errors", response.data) From 5947c7a0a7037a16f2f0399058129b619268b74e Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 29 Jun 2026 12:48:55 +0400 Subject: [PATCH 6/6] add documentation Signed-off-by: tdruez --- docs/howto-5-product-object-permissions.rst | 153 ++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/docs/howto-5-product-object-permissions.rst b/docs/howto-5-product-object-permissions.rst index 3bf017ec..236315b6 100644 --- a/docs/howto-5-product-object-permissions.rst +++ b/docs/howto-5-product-object-permissions.rst @@ -92,3 +92,156 @@ examples and not recommendations. You have now made the Product visible, and optionally editable, by DejaCode Users that are not superusers. + +4. Manage Product Object Permissions via the REST API +----------------------------------------------------- + +Product object permissions can also be managed programmatically through the REST API. +This is especially useful for CI/CD pipelines that create Product versions automatically +and need to assign permissions without manual intervention. + +The endpoint is available at:: + + /api/v2/products/{uuid}/permissions/ + +**Authentication** + +All requests require authentication. The examples below use an API key passed via +the ``Authorization`` header:: + + Authorization: Token + +**Available permissions** + +The following permission codenames can be assigned to users or groups: + +- ``view_product`` -- allows viewing the product +- ``change_product`` -- allows editing the product +- ``delete_product`` -- allows deleting the product + +**Finding the Product UUID** + +Retrieve the UUID from the product list endpoint:: + + GET /api/v2/products/?name=MyApp&version=2.0 + +The ``uuid`` field is included in each product entry of the response. + +4.1 List current permissions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Retrieve all users and groups that currently have permissions on a product:: + + GET /api/v2/products/{uuid}/permissions/ + +Response:: + + { + "users": [ + { + "dataspace": "nexB", + "username": "alice", + "object_permissions": ["view_product", "change_product"] + } + ], + "groups": [ + { + "name": "backend-team", + "object_permissions": ["view_product"] + } + ] + } + +4.2 Assign permissions to a user +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Provide ``user`` (username) and a ``permissions`` list:: + + POST /api/v2/products/{uuid}/permissions/ + Content-Type: application/json + + { + "user": "alice", + "permissions": ["view_product", "change_product"] + } + +Successful response:: + + {"status": "permissions assigned"} + +4.3 Assign permissions to a group +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use ``group`` (group name) instead of ``user``. All members of the group will +inherit the assigned permissions:: + + POST /api/v2/products/{uuid}/permissions/ + Content-Type: application/json + + { + "group": "backend-team", + "permissions": ["view_product"] + } + +This is the recommended approach when multiple users need access to the same set +of products. Manage group membership via the DejaCode admin, then assign the group +to each product once. + +4.4 Remove permissions from a user or group +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use the ``DELETE`` method with the same body format:: + + DELETE /api/v2/products/{uuid}/permissions/ + Content-Type: application/json + + { + "user": "alice", + "permissions": ["change_product"] + } + +Or for a group:: + + DELETE /api/v2/products/{uuid}/permissions/ + Content-Type: application/json + + { + "group": "backend-team", + "permissions": ["view_product"] + } + +Successful response:: + + {"status": "permissions removed"} + +4.5 Automate permissions in a CI/CD pipeline +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following shell script illustrates how to create a Product version and immediately +assign permissions to a group, so that team members can view it without any manual +step:: + + BASE_URL="https://dejacode.example.com/api/v2" + TOKEN="your-api-token" + GROUP="backend-team" + + # Create the product version + RESPONSE=$(curl -s -X POST "$BASE_URL/products/" \ + -H "Authorization: Token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "MyApp", "version": "3.0"}') + + UUID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['uuid'])") + + # Assign view permission to the team + curl -s -X POST "$BASE_URL/products/$UUID/permissions/" \ + -H "Authorization: Token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"group\": \"$GROUP\", \"permissions\": [\"view_product\"]}" + +**Access control for the permissions endpoint** + +Only the following users can call the ``/permissions/`` endpoint on a given product: + +- A **superuser** +- The user who **created** the product (``created_by`` field)