Skip to content

Commit

Permalink
Add a "Load Packages from SBOMs" Product action in the REST API #59
Browse files Browse the repository at this point in the history
Signed-off-by: tdruez <tdruez@nexb.com>
  • Loading branch information
tdruez committed Feb 29, 2024
1 parent aca7787 commit df9e85d
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 18 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Release notes
- Add dark theme support in UI.
https://github.com/nexB/dejacode/issues/25

- Add a "Load Packages from SBOMs" Product action in the REST API.
https://github.com/nexB/dejacode/issues/59

- Refactor the "Import manifest" feature as "Load SBOMs".
https://github.com/nexB/dejacode/issues/61

### Version 5.0.1

- Improve the stability of the "Check for new Package versions" feature.
Expand Down
45 changes: 45 additions & 0 deletions product_portfolio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
import django_filters
from rest_framework import permissions
from rest_framework import serializers
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response

from component_catalog.api import KeywordsField
from component_catalog.api import PackageEmbeddedSerializer
Expand All @@ -31,6 +34,7 @@
from dje.filters import NameVersionFilter
from dje.permissions import assign_all_object_permissions
from product_portfolio.filters import ComponentCompletenessAPIFilter
from product_portfolio.forms import LoadSBOMsForm
from product_portfolio.models import CodebaseResource
from product_portfolio.models import Product
from product_portfolio.models import ProductComponent
Expand Down Expand Up @@ -191,6 +195,25 @@ class Meta:
)


class LoadSBOMsFormSerializer(serializers.Serializer):
"""Serializer equivalent of LoadSBOMsForm, used for API documentation."""

input_file = serializers.FileField(
required=True,
help_text=LoadSBOMsForm.base_fields["input_file"].label,
)
update_existing_packages = serializers.BooleanField(
required=False,
default=False,
help_text=LoadSBOMsForm.base_fields["update_existing_packages"].help_text,
)
scan_all_packages = serializers.BooleanField(
required=False,
default=False,
help_text=LoadSBOMsForm.base_fields["scan_all_packages"].help_text,
)


class ProductViewSet(CreateRetrieveUpdateListViewSet):
queryset = Product.objects.none()
serializer_class = ProductSerializer
Expand Down Expand Up @@ -240,6 +263,28 @@ def perform_create(self, serializer):
super().perform_create(serializer)
assign_all_object_permissions(self.request.user, serializer.instance)

@action(detail=True, methods=["post"], serializer_class=LoadSBOMsFormSerializer)
def load_sboms(self, request, *args, **kwargs):
"""
Load Packages from SBOMs.
DejaCode supports the following SBOM formats:
* CycloneDX BOM as JSON bom.json and .cdx.json,
* SPDX document as JSON .spdx.json,
* AboutCode .ABOUT files,
Multiple SBOMs: You can provide multiple SBOMs by packaging them into a zip
archive. DejaCode will handle and process them accordingly.
"""
product = self.get_object()

form = LoadSBOMsForm(data=request.POST, files=request.FILES)
if not form.is_valid():
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)

form.submit(product=product, user=request.user)
return Response({"status": "SBOM file submitted to ScanCode.io for inspection."})


class BaseProductRelationSerializer(ValidateLicenseExpressionMixin, DataspacedSerializer):
product = NameVersionHyperlinkedRelatedField(
Expand Down
21 changes: 21 additions & 0 deletions product_portfolio/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#

from django import forms
from django.db import transaction
from django.forms import BaseModelFormSet
from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse_lazy
Expand All @@ -28,6 +29,7 @@
from component_catalog.license_expression_dje import LicenseExpressionFormMixin
from component_catalog.models import Component
from component_catalog.programming_languages import PROGRAMMING_LANGUAGES
from dje import tasks
from dje.fields import SmartFileField
from dje.forms import ColorCodeFormMixin
from dje.forms import DataspacedAdminForm
Expand All @@ -47,6 +49,7 @@
from product_portfolio.models import Product
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductPackage
from product_portfolio.models import ScanCodeProject


class NameVersionValidationFormMixin:
Expand Down Expand Up @@ -597,6 +600,24 @@ def helper(self):
helper.add_input(Submit("submit", "Load Packages", css_class="btn-success"))
return helper

def submit(self, product, user):
scancode_project = ScanCodeProject.objects.create(
product=product,
dataspace=product.dataspace,
type=ScanCodeProject.ProjectType.LOAD_SBOMS,
input_file=self.cleaned_data.get("input_file"),
update_existing_packages=self.cleaned_data.get("update_existing_packages"),
scan_all_packages=self.cleaned_data.get("scan_all_packages"),
created_by=user,
)

transaction.on_commit(
lambda: tasks.scancodeio_submit_load_sbom.delay(
scancodeproject_uuid=scancode_project.uuid,
user_uuid=user.uuid,
)
)


class StrongTextWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None):
Expand Down
29 changes: 29 additions & 0 deletions product_portfolio/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json

from django.core import mail
from django.core.files.base import ContentFile
from django.test import TestCase
from django.urls import reverse

Expand Down Expand Up @@ -41,6 +42,7 @@
from product_portfolio.models import ProductPackage
from product_portfolio.models import ProductRelationStatus
from product_portfolio.models import ProductStatus
from product_portfolio.models import ScanCodeProject


class ProductAPITestCase(MaxQueryMixin, TestCase):
Expand Down Expand Up @@ -1125,3 +1127,30 @@ def test_api_codebaseresource_endpoints_tab_permission(self):
from dje.api_custom import TabPermission # Prevent circular import

self.assertEqual((TabPermission,), CodebaseResourceViewSet.extra_permissions)

def test_api_product_endpoint_load_sboms_action(self):
url = reverse("api_v2:product-load-sboms", args=[self.product1.uuid])

self.client.login(username=self.base_user.username, password="secret")
response = self.client.post(url, data={})
self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)

# Required permissions
add_perm(self.base_user, "add_product")
assign_perm("view_product", self.base_user, self.product1)

response = self.client.post(url, data={})
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
expected = {"input_file": ["This field is required."]}
self.assertEqual(expected, response.data)

data = {
"input_file": ContentFile("Content", name="sbom.json"),
"update_existing_packages": False,
"scan_all_packages": False,
}
response = self.client.post(url, data)
self.assertEqual(status.HTTP_200_OK, response.status_code)
expected = {"status": "SBOM file submitted to ScanCode.io for inspection."}
self.assertEqual(expected, response.data)
self.assertEqual(1, ScanCodeProject.objects.count())
19 changes: 1 addition & 18 deletions product_portfolio/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1882,24 +1882,7 @@ def get_success_url(self):

def form_valid(self, form):
self.object = self.get_object()

scancode_project = ScanCodeProject.objects.create(
product=self.object,
dataspace=self.object.dataspace,
type=ScanCodeProject.ProjectType.LOAD_SBOMS,
input_file=form.cleaned_data.get("input_file"),
update_existing_packages=form.cleaned_data.get("update_existing_packages"),
scan_all_packages=form.cleaned_data.get("scan_all_packages"),
created_by=self.request.user,
)

transaction.on_commit(
lambda: tasks.scancodeio_submit_load_sbom.delay(
scancodeproject_uuid=scancode_project.uuid,
user_uuid=self.request.user.uuid,
)
)

form.submit(product=self.object, user=self.request.user)
msg = "SBOM file submitted to ScanCode.io for inspection."
messages.success(self.request, msg)
return super().form_valid(form)
Expand Down

0 comments on commit df9e85d

Please sign in to comment.