Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions dje/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
171 changes: 171 additions & 0 deletions dje/api_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#
# 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.contrib.auth.models import Group
from django.core.exceptions import ObjectDoesNotExist

from guardian.shortcuts import assign_perm
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
from rest_framework import serializers
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response

from dje.api import DataspacedSlugRelatedField

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).
"""

owner_field = "created_by"

def has_object_permission(self, request, view, obj):
user = request.user
if not user.is_authenticated:
return False

if user.is_superuser:
return True

owner_field = getattr(view, "owner_field", self.owner_field)
owner = getattr(obj, owner_field, None)
return owner == user


class ObjectPermissionSerializer(serializers.Serializer):
"""
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",
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", "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 for
both individual users and groups.

GET /api/{model}/{uuid}/permissions/
POST /api/{model}/{uuid}/permissions/
DELETE /api/{model}/{uuid}/permissions/
"""

@action(
detail=True,
methods=["get", "post", "delete"],
url_path="permissions",
serializer_class=ObjectPermissionSerializer,
permission_classes=[permissions.IsAuthenticated, CanManageObjectPermissions],
)
def manage_permissions(self, request, *args, **kwargs):
"""
Manage object-level permissions for this object.

- 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)
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)

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, target, obj)
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)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

return Response({"status": "permissions assigned"}, status=status.HTTP_200_OK)

if request.method == "DELETE":
errors = []
for perm in perms:
try:
remove_perm(perm, target, obj)
except ObjectDoesNotExist:
errors.append(f"Cannot remove permission '{perm}' due to an internal error.")

if errors:
return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

return Response({"status": "permissions removed"}, status=status.HTTP_200_OK)
153 changes: 153 additions & 0 deletions docs/howto-5-product-object-permissions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your-api-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)
2 changes: 2 additions & 0 deletions product_portfolio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -343,6 +344,7 @@ class Meta:


class ProductViewSet(
ObjectPermissionsMixin,
SendAboutFilesMixin,
AboutCodeFilesActionMixin,
SPDXDocumentActionMixin,
Expand Down
Loading
Loading