From df9e85d14855d593e7cfdf76fbd5169d33dfb680 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 29 Feb 2024 14:28:37 +0100 Subject: [PATCH] Add a "Load Packages from SBOMs" Product action in the REST API #59 Signed-off-by: tdruez --- CHANGELOG.rst | 6 ++++ product_portfolio/api.py | 45 +++++++++++++++++++++++++++++ product_portfolio/forms.py | 21 ++++++++++++++ product_portfolio/tests/test_api.py | 29 +++++++++++++++++++ product_portfolio/views.py | 19 +----------- 5 files changed, 102 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 094a4f1c..987aea13 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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. diff --git a/product_portfolio/api.py b/product_portfolio/api.py index 869e5a1d..196fdc01 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -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 @@ -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 @@ -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 @@ -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( diff --git a/product_portfolio/forms.py b/product_portfolio/forms.py index 5c5bb8b9..7a601a0c 100644 --- a/product_portfolio/forms.py +++ b/product_portfolio/forms.py @@ -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 @@ -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 @@ -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: @@ -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): diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 46c270ab..7845d440 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -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 @@ -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): @@ -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()) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 46653a62..9a750f5e 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -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)