diff --git a/backend/core/startup.py b/backend/core/startup.py index d74419221..833cff177 100644 --- a/backend/core/startup.py +++ b/backend/core/startup.py @@ -339,6 +339,30 @@ "view_filteringlabel", "change_filteringlabel", "delete_filteringlabel", + "add_ebiosrmstudy", + "view_ebiosrmstudy", + "change_ebiosrmstudy", + "delete_ebiosrmstudy", + "add_fearedevent", + "view_fearedevent", + "change_fearedevent", + "delete_fearedevent", + "add_roto", + "view_roto", + "change_roto", + "delete_roto", + "add_stakeholder", + "view_stakeholder", + "change_stakeholder", + "delete_stakeholder", + "add_attackpath", + "view_attackpath", + "change_attackpath", + "delete_attackpath", + "add_operationalscenario", + "view_operationalscenario", + "change_operationalscenario", + "delete_operationalscenario", ] THIRD_PARTY_RESPONDENT_PERMISSIONS_LIST = [ diff --git a/backend/core/urls.py b/backend/core/urls.py index 5c5c5ddec..d0aa4054d 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -83,6 +83,7 @@ path("iam/", include("iam.urls")), path("serdes/", include("serdes.urls")), path("settings/", include("global_settings.urls")), + path("ebios-rm/", include("ebios_rm.urls")), path("csrf/", get_csrf_token, name="get_csrf_token"), path("build/", get_build, name="get_build"), path("evidences//upload/", UploadAttachmentView.as_view(), name="upload"), diff --git a/backend/core/views.py b/backend/core/views.py index ba3aadc7f..be431f545 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -102,7 +102,10 @@ def get_queryset(self): return None object_ids_view = None if self.request.method == "GET": - if q := re.match("/api/[\w-]+/([0-9a-f-]+)", self.request.path): + if q := re.match( + "/api/[\w-]+/([\w-]+/)?([0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}(,[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})+)", + self.request.path, + ): """"get_queryset is called by Django even for an individual object via get_object https://stackoverflow.com/questions/74048193/why-does-a-retrieve-request-end-up-calling-get-queryset""" id = UUID(q.group(1)) diff --git a/backend/ebios_rm/migrations/0001_initial.py b/backend/ebios_rm/migrations/0001_initial.py index b0fb9dc58..66af48c5f 100644 --- a/backend/ebios_rm/migrations/0001_initial.py +++ b/backend/ebios_rm/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-12-03 12:57 +# Generated by Django 5.1.1 on 2024-12-04 13:02 import django.core.validators import django.db.models.deletion @@ -54,7 +54,7 @@ class Migration(migrations.Migration): "due_date", models.DateField(blank=True, null=True, verbose_name="Due date"), ), - ("ref_id", models.CharField(max_length=100)), + ("ref_id", models.CharField(blank=True, max_length=100)), ( "version", models.CharField( @@ -205,6 +205,15 @@ class Migration(migrations.Migration): "justification", models.TextField(blank=True, verbose_name="Justification"), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ( "ebios_rm_study", models.ForeignKey( @@ -249,7 +258,7 @@ class Migration(migrations.Migration): "description", models.TextField(blank=True, null=True, verbose_name="Description"), ), - ("ref_id", models.CharField(max_length=100)), + ("ref_id", models.CharField(blank=True, max_length=100)), ( "gravity", models.SmallIntegerField(default=-1, verbose_name="Gravity"), @@ -280,6 +289,15 @@ class Migration(migrations.Migration): verbose_name="EBIOS RM study", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ( "qualifications", models.ManyToManyField( @@ -352,6 +370,15 @@ class Migration(migrations.Migration): verbose_name="EBIOS RM study", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ( "threats", models.ManyToManyField( @@ -488,6 +515,15 @@ class Migration(migrations.Migration): verbose_name="Feared events", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ], options={ "verbose_name": "RO/TO couple", @@ -655,6 +691,15 @@ class Migration(migrations.Migration): verbose_name="Entity", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ], options={ "verbose_name": "Stakeholder", diff --git a/backend/ebios_rm/models.py b/backend/ebios_rm/models.py index c800aa705..99104508d 100644 --- a/backend/ebios_rm/models.py +++ b/backend/ebios_rm/models.py @@ -64,7 +64,7 @@ class Status(models.TextChoices): default=Entity.get_main_entity, ) - ref_id = models.CharField(max_length=100) + ref_id = models.CharField(max_length=100, blank=True) version = models.CharField( max_length=100, blank=True, @@ -101,7 +101,7 @@ class Meta: ordering = ["created_at"] -class FearedEvent(NameDescriptionMixin): +class FearedEvent(NameDescriptionMixin, FolderMixin): ebios_rm_study = models.ForeignKey( EbiosRMStudy, verbose_name=_("EBIOS RM study"), @@ -122,7 +122,7 @@ class FearedEvent(NameDescriptionMixin): help_text=_("Qualifications carried by the feared event"), ) - ref_id = models.CharField(max_length=100) + ref_id = models.CharField(max_length=100, blank=True) gravity = models.SmallIntegerField(default=-1, verbose_name=_("Gravity")) is_selected = models.BooleanField(verbose_name=_("Is selected"), default=False) justification = models.TextField(verbose_name=_("Justification"), blank=True) @@ -132,8 +132,12 @@ class Meta: verbose_name_plural = _("Feared events") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) -class RoTo(AbstractBaseModel): + +class RoTo(AbstractBaseModel, FolderMixin): class RiskOrigin(models.TextChoices): STATE = "state", _("State") ORGANIZED_CRIME = "organized_crime", _("Organized crime") @@ -206,8 +210,12 @@ class Meta: verbose_name_plural = _("RO/TO couples") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) + -class Stakeholder(AbstractBaseModel): +class Stakeholder(AbstractBaseModel, FolderMixin): class Category(models.TextChoices): CLIENT = "client", _("Client") PARTNER = "partner", _("Partner") @@ -289,6 +297,10 @@ class Meta: verbose_name_plural = _("Stakeholders") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) + @staticmethod def _compute_criticality( dependency: int, penetration: int, maturity: int, trust: int @@ -316,7 +328,7 @@ def residual_criticality(self): ) -class AttackPath(AbstractBaseModel): +class AttackPath(AbstractBaseModel, FolderMixin): ebios_rm_study = models.ForeignKey( EbiosRMStudy, verbose_name=_("EBIOS RM study"), @@ -344,8 +356,12 @@ class Meta: verbose_name_plural = _("Attack paths") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) -class OperationalScenario(AbstractBaseModel): + +class OperationalScenario(AbstractBaseModel, FolderMixin): ebios_rm_study = models.ForeignKey( EbiosRMStudy, verbose_name=_("EBIOS RM study"), @@ -375,3 +391,7 @@ class Meta: verbose_name = _("Operational scenario") verbose_name_plural = _("Operational scenarios") ordering = ["created_at"] + + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py new file mode 100644 index 000000000..3507bf69f --- /dev/null +++ b/backend/ebios_rm/serializers.py @@ -0,0 +1,74 @@ +from core.serializers import ( + BaseModelSerializer, + FieldsRelatedField, + AssessmentReadSerializer, +) +from core.models import StoredLibrary, RiskMatrix +from .models import EbiosRMStudy, FearedEvent +from rest_framework import serializers +import logging + + +class EbiosRMStudyWriteSerializer(BaseModelSerializer): + risk_matrix = serializers.PrimaryKeyRelatedField( + queryset=RiskMatrix.objects.all(), required=False + ) + + def create(self, validated_data): + if not validated_data.get("risk_matrix"): + try: + ebios_matrix = RiskMatrix.objects.filter( + urn="urn:intuitem:risk:matrix:risk-matrix-4x4-ebios-rm" + ).first() + if not ebios_matrix: + ebios_matrix_library = StoredLibrary.objects.get( + urn="urn:intuitem:risk:library:risk-matrix-4x4-ebios-rm" + ) + ebios_matrix_library.load() + ebios_matrix = RiskMatrix.objects.get( + urn="urn:intuitem:risk:matrix:risk-matrix-4x4-ebios-rm" + ) + validated_data["risk_matrix"] = ebios_matrix + except (StoredLibrary.DoesNotExist, RiskMatrix.DoesNotExist) as e: + logging.error(f"Error loading risk matrix: {str(e)}") + raise serializers.ValidationError( + "An error occurred while loading the risk matrix." + ) + return super().create(validated_data) + + class Meta: + model = EbiosRMStudy + exclude = ["created_at", "updated_at"] + + +class EbiosRMStudyReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + project = FieldsRelatedField(["id", "folder"]) + folder = FieldsRelatedField() + risk_matrix = FieldsRelatedField() + reference_entity = FieldsRelatedField() + assets = FieldsRelatedField(many=True) + compliance_assessments = FieldsRelatedField(many=True) + risk_assessments = FieldsRelatedField(many=True) + authors = FieldsRelatedField(many=True) + reviewers = FieldsRelatedField(many=True) + + class Meta: + model = EbiosRMStudy + fields = "__all__" + + +class FearedEventWriteSerializer(BaseModelSerializer): + class Meta: + model = FearedEvent + exclude = ["created_at", "updated_at", "folder"] + + +class FearedEventReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + ebios_rm_study = FieldsRelatedField() + folder = FieldsRelatedField() + + class Meta: + model = FearedEvent + fields = "__all__" diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py new file mode 100644 index 000000000..47ab37bcf --- /dev/null +++ b/backend/ebios_rm/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework import routers + +from ebios_rm.views import EbiosRMStudyViewSet, FearedEventViewSet + +router = routers.DefaultRouter() + +router.register(r"studies", EbiosRMStudyViewSet, basename="studies") +router.register(r"feared-events", FearedEventViewSet, basename="feared-events") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 91ea44a21..69676bf77 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,3 +1,29 @@ -from django.shortcuts import render +from core.views import BaseModelViewSet as AbstractBaseModelViewSet +from .models import EbiosRMStudy, FearedEvent +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from rest_framework.decorators import action +from rest_framework.response import Response -# Create your views here. +LONG_CACHE_TTL = 60 # mn + + +class BaseModelViewSet(AbstractBaseModelViewSet): + serializers_module = "ebios_rm.serializers" + + +class EbiosRMStudyViewSet(BaseModelViewSet): + """ + API endpoint that allows ebios rm studies to be viewed or edited. + """ + + model = EbiosRMStudy + + @method_decorator(cache_page(60 * LONG_CACHE_TTL)) + @action(detail=False, name="Get status choices") + def status(self, request): + return Response(dict(EbiosRMStudy.Status.choices)) + + +class FearedEventViewSet(BaseModelViewSet): + model = FearedEvent