diff --git a/backend/core/helpers.py b/backend/core/helpers.py index 7b50a30cc..a6792e8ff 100644 --- a/backend/core/helpers.py +++ b/backend/core/helpers.py @@ -1278,7 +1278,7 @@ def duplicate_and_link_object(new_obj, duplicate_object, target_folder, field_na # Get parent and sub-folders of the target folder target_parent_folders = target_folder.get_parent_folders() - sub_folders = target_folder.sub_folders() + sub_folders = target_folder.get_sub_folders() # Get all related objects for the specified field related_objects = getattr(source_object, field_name).all() diff --git a/backend/core/models.py b/backend/core/models.py index 1a72b87f2..5ab669cfd 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -1787,9 +1787,6 @@ def risk_assessments(self): def projects(self): return {risk_assessment.project for risk_assessment in self.risk_assessments} - def parent_project(self): - pass - def __str__(self): return self.name diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 1b2661a14..c8490d807 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -338,8 +338,15 @@ class AppliedControlReadSerializer(AppliedControlWriteSerializer): ranking_score = serializers.IntegerField(source="get_ranking_score") owner = FieldsRelatedField(many=True) - has_evidences = serializers.BooleanField() - eta_missed = serializers.BooleanField() + # These properties shouldn't be displayed in the frontend detail view as they are simple derivations from fields already displayed in the detail view. + # has_evidences = serializers.BooleanField() + # eta_missed = serializers.BooleanField() + + +class AppliedControlDuplicateSerializer(BaseModelSerializer): + class Meta: + model = AppliedControl + fields = ["name", "description", "folder"] class PolicyWriteSerializer(AppliedControlWriteSerializer): diff --git a/backend/core/views.py b/backend/core/views.py index 73a74924e..12e99d5f6 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1154,6 +1154,53 @@ def get_timeline_info(self, request): colorMap[domain.name] = next(color_cycle) return Response({"entries": entries, "colorMap": colorMap}) + @action( + detail=True, + name="Duplicate applied control", + methods=["post"], + serializer_class=AppliedControlDuplicateSerializer, + ) + def duplicate(self, request, pk): + (object_ids_view, _, _) = RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), request.user, AppliedControl + ) + if UUID(pk) not in object_ids_view: + return Response( + {"results": "applied control duplicated"}, + status=status.HTTP_404_NOT_FOUND, + ) + + applied_control = self.get_object() + data = request.data + new_folder = Folder.objects.get(id=data["folder"]) + duplicate_applied_control = AppliedControl.objects.create( + reference_control=applied_control.reference_control, + name=data["name"], + description=data["description"], + folder=new_folder, + ref_id=applied_control.ref_id, + category=applied_control.category, + csf_function=applied_control.csf_function, + priority=applied_control.priority, + status=applied_control.status, + start_date=applied_control.start_date, + eta=applied_control.eta, + expiry_date=applied_control.expiry_date, + link=applied_control.link, + effort=applied_control.effort, + cost=applied_control.cost, + ) + duplicate_applied_control.owner.set(applied_control.owner.all()) + if data["duplicate_evidences"]: + duplicate_related_objects( + applied_control, duplicate_applied_control, new_folder, "evidences" + ) + duplicate_applied_control.save() + + return Response( + {"results": AppliedControlReadSerializer(duplicate_applied_control).data} + ) + @action(detail=False, methods=["get"]) def ids(self, request): my_map = dict() diff --git a/backend/iam/models.py b/backend/iam/models.py index e67ce56ec..b25cb9ee7 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -2,7 +2,7 @@ Inspired from Azure IAM model""" from collections import defaultdict -from typing import Any, List, Self, Tuple +from typing import Any, List, Self, Tuple, Generator import uuid from allauth.account.models import EmailAddress from django.utils import timezone @@ -101,24 +101,22 @@ class Meta: def __str__(self) -> str: return self.name.__str__() - def sub_folders(self) -> List[Self]: + def get_sub_folders(self) -> Generator[Self, None, None]: """Return the list of subfolders""" - def sub_folders_in(f, sub_folder_list): - for sub_folder in f.folder_set.all(): - sub_folder_list.append(sub_folder) - sub_folders_in(sub_folder, sub_folder_list) - return sub_folder_list + def sub_folders_in(folder): + for sub_folder in folder.folder_set.all(): + yield sub_folder + yield from sub_folders_in(sub_folder) - return sub_folders_in(self, []) + yield from sub_folders_in(self) - def get_parent_folders(self) -> List[Self]: + # Should we update data-model.md now that this method is a generator ? + def get_parent_folders(self) -> Generator[Self, None, None]: """Return the list of parent folders""" - return ( - [self.parent_folder] + Folder.get_parent_folders(self.parent_folder) - if self.parent_folder - else [] - ) + current_folder = self + while (current_folder := current_folder.parent_folder) is not None: + yield current_folder @staticmethod def _navigate_structure(start, path): @@ -657,11 +655,11 @@ def get_accessible_folders( ]: for f in ra.perimeter_folders.all(): folders_set.add(f) - folders_set.update(f.sub_folders()) + folders_set.update(f.get_sub_folders()) # calculate perimeter perimeter = set() perimeter.add(folder) - perimeter.update(folder.sub_folders()) + perimeter.update(folder.get_sub_folders()) # return filtered result return [ x.id @@ -698,7 +696,7 @@ def get_accessible_object_ids( folder_for_object = {x: Folder.get_folder(x) for x in all_objects} perimeter = set() perimeter.add(folder) - perimeter.update(folder.sub_folders()) + perimeter.update(folder.get_sub_folders()) for ra in [ x for x in RoleAssignment.get_role_assignments(user) @@ -707,7 +705,7 @@ def get_accessible_object_ids( ra_permissions = ra.role.permissions.all() for my_folder in perimeter & set(ra.perimeter_folders.all()): target_folders = ( - [my_folder] + my_folder.sub_folders() + [my_folder, *my_folder.get_sub_folders()] if ra.is_recursive else [my_folder] ) diff --git a/frontend/messages/ar.json b/frontend/messages/ar.json index ec7de51fd..1bcdb4f62 100644 --- a/frontend/messages/ar.json +++ b/frontend/messages/ar.json @@ -679,6 +679,7 @@ "back": "خلف", "duplicate": "ينسخ", "duplicateRiskAssessment": "تكرار تقييم المخاطر", + "duplicateAppliedControl": "تكرار عنصر التحكم المطبق", "size": "الحجم", "favicon": "الرمز المفضل", "logo": "الشعار", @@ -862,5 +863,7 @@ "tagsHelpText": "تُستخدم العلامات لتصنيف العناصر وتصفيتها. يمكنك إضافة علامات في قسم \"إضافي\"", "forgotPassword": "هل نسيت كلمة السر", "scoreSemiColon": "نتيجة:", - "mappingInferenceHelpText": "هذه المتغيرات ثابتة ولن تتغير اعتمادًا على المصدر." + "mappingInferenceHelpText": "هذه المتغيرات ثابتة ولن تتغير اعتمادًا على المصدر.", + "bringTheEvidences": "أحضر الأدلة", + "bringTheEvidencesHelpText": "في حالة التعطيل، سيتم تكرار الكائن بدون أدلته" } diff --git a/frontend/messages/cz.json b/frontend/messages/cz.json index 8627041f5..20895bae2 100644 --- a/frontend/messages/cz.json +++ b/frontend/messages/cz.json @@ -852,5 +852,7 @@ "exploreButton": "Prozkoumat", "tags": "Štítky", "addTag": "Přidat štítek", - "tagsHelpText": "Štítky pomáhají kategorizovat a filtrovat více objektů. Můžete je spravovat v menu Extra." + "tagsHelpText": "Štítky pomáhají kategorizovat a filtrovat více objektů. Můžete je spravovat v menu Extra.", + "bringTheEvidences": "Přineste důkazy", + "bringTheEvidencesHelpText": "Pokud je zakázáno, objekt bude duplikován bez jeho důkazů" } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 75a2f2542..3be9296d1 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -678,6 +678,7 @@ "back": "Zurückkehren", "duplicate": "Duplikat", "duplicateRiskAssessment": "Duplizieren Sie die Risikobewertung", + "duplicateAppliedControl": "Duplizieren Sie das angewendete kontrolle", "size": "Größe", "favicon": "Favicon", "logo": "Logo", @@ -861,5 +862,7 @@ "tagsHelpText": "Tags werden zum Kategorisieren und Filtern der Elemente verwendet. Sie können Tags im Abschnitt Extra hinzufügen", "forgotPassword": "Passwort vergessen", "scoreSemiColon": "Punktzahl:", - "mappingInferenceHelpText": "Diese Variablen sind fest und ändern sich je nach Quelle nicht." + "mappingInferenceHelpText": "Diese Variablen sind fest und ändern sich je nach Quelle nicht.", + "bringTheEvidences": "Bringen Sie die Beweise", + "bringTheEvidencesHelpText": "Wenn deaktiviert, wird das Objekt ohne seine Beweise dupliziert" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8be7b78d0..9d5b87a36 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -682,6 +682,7 @@ "back": "Back", "duplicate": "Duplicate", "duplicateRiskAssessment": "Duplicate the risk assessment", + "duplicateAppliedControl": "Duplicate the applied control", "size": "Size", "favicon": "Favicon", "logo": "Logo", @@ -919,5 +920,7 @@ "ebiosWs5_3": "Define security measures", "ebiosWs5_4": "Assess and document residual risks", "ebiosWs5_5": "Establish risk monitoring framework", - "activity": "Activity" + "activity": "Activity", + "bringTheEvidences": "Bring the evidences", + "bringTheEvidencesHelpText": "If disabled, the object will be duplicated without its evidences" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index e1589f59f..6d3af4836 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -678,6 +678,7 @@ "back": "Devolver", "duplicate": "Duplicar", "duplicateRiskAssessment": "Duplicar la evaluación de riesgo", + "duplicateAppliedControl": "Duplicar el control aplicado", "size": "Tamaño", "favicon": "Favicon", "logo": "Logo", @@ -861,5 +862,7 @@ "tagsHelpText": "Las etiquetas se utilizan para categorizar y filtrar los elementos. Puedes agregar etiquetas en la sección Extra", "forgotPassword": "Has olvidado tu contraseña", "scoreSemiColon": "Puntaje:", - "mappingInferenceHelpText": "Estas variables son fijas y no cambiarán dependiendo de la fuente." + "mappingInferenceHelpText": "Estas variables son fijas y no cambiarán dependiendo de la fuente.", + "bringTheEvidences": "Traer las evidencias", + "bringTheEvidencesHelpText": "Si está deshabilitado, el objeto se duplicará sin sus evidencias." } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 29ce9296c..a67e21bca 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -682,6 +682,7 @@ "back": "Retour", "duplicate": "Dupliquer", "duplicateRiskAssessment": "Dupliquer l’évaluation de risque", + "duplicateAppliedControl": "Dupliquer la mesure appliquée", "size": "Taille", "favicon": "Icône de favori", "logo": "Logo", @@ -895,5 +896,7 @@ "ebiosWs5_3": "Définir les mesures de sécurité", "ebiosWs5_4": "Évaluer et documenter les risques résiduels", "ebiosWs5_5": "Mettre en place le cadre de suivi des risques", - "activity": "Activité" + "activity": "Activité", + "bringTheEvidences": "Apportez les preuves", + "bringTheEvidencesHelpText": "Si désactivé, l'objet sera dupliqué sans ses preuves" } diff --git a/frontend/messages/hi.json b/frontend/messages/hi.json index addacbc67..051d2e76c 100644 --- a/frontend/messages/hi.json +++ b/frontend/messages/hi.json @@ -678,6 +678,7 @@ "back": "वापस", "duplicate": "प्रतिलिपि", "duplicateRiskAssessment": "जोखिम आकलन की प्रतिलिपि बनाएँ", + "duplicateAppliedControl": "लागू नियंत्रण की प्रतिलिपि बनाएँ", "size": "आकार", "favicon": "फ़ेविकॉन", "logo": "प्रतीक चिन्ह", @@ -861,5 +862,7 @@ "tagsHelpText": "टैग का उपयोग आइटम को वर्गीकृत और फ़िल्टर करने के लिए किया जाता है। आप अतिरिक्त अनुभाग में टैग जोड़ सकते हैं", "forgotPassword": "पासवर्ड भूल गए", "scoreSemiColon": "अंक:", - "mappingInferenceHelpText": "ये चर निश्चित हैं और स्रोत के आधार पर परिवर्तित नहीं होंगे।" + "mappingInferenceHelpText": "ये चर निश्चित हैं और स्रोत के आधार पर परिवर्तित नहीं होंगे।", + "bringTheEvidences": "सबूत लाओ", + "bringTheEvidencesHelpText": "यदि अक्षम किया गया है, तो ऑब्जेक्ट को उसके साक्ष्य के बिना डुप्लिकेट किया जाएगा" } diff --git a/frontend/messages/it.json b/frontend/messages/it.json index 66e002154..837ab5dea 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -678,6 +678,7 @@ "back": "Ripetere", "duplicate": "Duplicare", "duplicateRiskAssessment": "Duplicare la valutazione del rischio", + "duplicateAppliedControl": "Duplica il controllo applicato", "size": "Dimensione", "favicon": "Icona preferita", "logo": "Logo", @@ -861,5 +862,7 @@ "tagsHelpText": "I tag vengono utilizzati per categorizzare e filtrare gli elementi. Puoi aggiungere tag nella sezione Extra", "forgotPassword": "Ha dimenticato la password", "scoreSemiColon": "Punto:", - "mappingInferenceHelpText": "Queste variabili sono fisse e non cambiano a seconda della fonte." + "mappingInferenceHelpText": "Queste variabili sono fisse e non cambiano a seconda della fonte.", + "bringTheEvidences": "Portare le prove", + "bringTheEvidencesHelpText": "Se disabilitato, l'oggetto verrà duplicato senza le sue prove" } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 116bb03f8..fab932bd3 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -678,6 +678,7 @@ "back": "Opbrengst", "duplicate": "Duplicaat", "duplicateRiskAssessment": "Dupliceer de risicobeoordeling", + "duplicateAppliedControl": "Dupliceer de toegepaste controle", "size": "Grootte", "favicon": "Favorieten", "logo": "Logo", @@ -861,5 +862,7 @@ "tagsHelpText": "Tags worden gebruikt om de items te categoriseren en te filteren. U kunt tags toevoegen in de sectie Extra", "forgotPassword": "Wachtwoord vergeten", "scoreSemiColon": "Punt:", - "mappingInferenceHelpText": "Deze variabelen zijn vast en veranderen niet, afhankelijk van de bron." + "mappingInferenceHelpText": "Deze variabelen zijn vast en veranderen niet, afhankelijk van de bron.", + "bringTheEvidences": "Breng de bewijzen", + "bringTheEvidencesHelpText": "Als dit is uitgeschakeld, wordt het object gedupliceerd zonder de bijbehorende bewijzen" } diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 06e202ddc..1991efea2 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -678,6 +678,7 @@ "back": "Powrót", "duplicate": "Duplikować", "duplicateRiskAssessment": "Powielić ocenę ryzyka", + "duplicateAppliedControl": "Duplikuj zastosowaną kontrolę", "size": "Rozmiar", "favicon": "Favicon", "logo": "Logo", @@ -861,5 +862,7 @@ "tagsHelpText": "Tagi służą do kategoryzowania i filtrowania elementów. Możesz dodać tagi w sekcji Extra", "forgotPassword": "Zapomniałem hasła", "scoreSemiColon": "Wynik:", - "mappingInferenceHelpText": "Te zmienne są stałe i nie zmieniają się w zależności od źródła." + "mappingInferenceHelpText": "Te zmienne są stałe i nie zmieniają się w zależności od źródła.", + "bringTheEvidences": "Przynieś dowody", + "bringTheEvidencesHelpText": "Jeśli wyłączone, obiekt zostanie zduplikowany bez dowodów" } diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 139456ede..4ecdb5a0a 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -678,6 +678,7 @@ "back": "Retornar", "duplicate": "Duplicado", "duplicateRiskAssessment": "Duplicar a avaliação de risco", + "duplicateAppliedControl": "Duplicar o controle aplicado", "size": "Tamanho", "favicon": "Favicon", "logo": "Logotipo", @@ -861,5 +862,7 @@ "tagsHelpText": "As tags são usadas para categorizar e filtrar os itens. Você pode adicionar tags na seção Extra", "forgotPassword": "Esqueceu sua senha", "scoreSemiColon": "Pontuação:", - "mappingInferenceHelpText": "Essas variáveis são fixas e não mudarão dependendo da fonte." + "mappingInferenceHelpText": "Essas variáveis são fixas e não mudarão dependendo da fonte.", + "bringTheEvidences": "Traga as evidências", + "bringTheEvidencesHelpText": "Se desabilitado, o objeto será duplicado sem suas evidências" } diff --git a/frontend/messages/ro.json b/frontend/messages/ro.json index 86c4c3212..09b399431 100644 --- a/frontend/messages/ro.json +++ b/frontend/messages/ro.json @@ -678,6 +678,7 @@ "back": "Înapoi", "duplicate": "Dublică", "duplicateRiskAssessment": "Dublarea evaluării riscului", + "duplicateAppliedControl": "Duplicați control aplicat", "size": "Mărime", "favicon": "Favicon", "logo": "Logo", @@ -861,5 +862,7 @@ "tagsHelpText": "Etichetele sunt folosite pentru a clasifica și filtra articolele. Puteți adăuga etichete în secțiunea Extra", "forgotPassword": "Aţi uitat parola", "scoreSemiColon": "Scor:", - "mappingInferenceHelpText": "Aceste variabile sunt fixe și nu se vor modifica în funcție de sursă." + "mappingInferenceHelpText": "Aceste variabile sunt fixe și nu se vor modifica în funcție de sursă.", + "bringTheEvidences": "Aduceți dovezile", + "bringTheEvidencesHelpText": "Dacă este dezactivat, obiectul va fi duplicat fără dovezile sale" } diff --git a/frontend/messages/sv.json b/frontend/messages/sv.json index 69ae0d8d8..aeee7ca16 100644 --- a/frontend/messages/sv.json +++ b/frontend/messages/sv.json @@ -862,5 +862,7 @@ "forgotPassword": "Glömt lösenord", "ssoSettingsUpdated": "SSO-inställningar uppdaterade", "scoreSemiColon": "Göra:", - "mappingInferenceHelpText": "Dessa variabler är fasta och kommer inte att ändras beroende på källan." + "mappingInferenceHelpText": "Dessa variabler är fasta och kommer inte att ändras beroende på källan.", + "bringTheEvidences": "Kom med bevisen", + "bringTheEvidencesHelpText": "Om det är inaktiverat kommer objektet att dupliceras utan dess bevis" } diff --git a/frontend/messages/ur.json b/frontend/messages/ur.json index 13c5db0b6..0d822959d 100644 --- a/frontend/messages/ur.json +++ b/frontend/messages/ur.json @@ -678,6 +678,7 @@ "back": "واپس", "duplicate": "نقل کریں", "duplicateRiskAssessment": "خطرے کی تشخیص کو نقل کریں", + "duplicateAppliedControl": "لاگو کنٹرول کی نقل تیار کریں۔", "size": "سائز", "favicon": "فیویکان", "logo": "لوگو", @@ -861,5 +862,7 @@ "tagsHelpText": "ٹیگز اشیاء کی درجہ بندی اور فلٹر کرنے کے لیے استعمال ہوتے ہیں۔ آپ اضافی سیکشن میں ٹیگ شامل کر سکتے ہیں۔", "forgotPassword": "پاس ورڈ بھول گئے۔", "scoreSemiColon": "سکور:", - "mappingInferenceHelpText": "یہ متغیرات طے شدہ ہیں اور ماخذ کے لحاظ سے تبدیل نہیں ہوں گے۔" + "mappingInferenceHelpText": "یہ متغیرات طے شدہ ہیں اور ماخذ کے لحاظ سے تبدیل نہیں ہوں گے۔", + "bringTheEvidences": "ثبوت لے کر آئیں", + "bringTheEvidencesHelpText": "اگر غیر فعال ہو تو، اعتراض کو اس کے ثبوت کے بغیر نقل کر دیا جائے گا۔" } diff --git a/frontend/src/lib/components/DetailView/DetailView.svelte b/frontend/src/lib/components/DetailView/DetailView.svelte index a40297fe9..4df9af7c4 100644 --- a/frontend/src/lib/components/DetailView/DetailView.svelte +++ b/frontend/src/lib/components/DetailView/DetailView.svelte @@ -125,6 +125,26 @@ modalStore.trigger(modal); } + function modalAppliedControlDuplicateForm(): void { + const modalComponent: ModalComponent = { + ref: CreateModal, + props: { + form: data.duplicateForm, + model: data.model, + debug: false, + duplicate: true, + formAction: '?/duplicate' + } + }; + + const modal: ModalSettings = { + type: 'component', + component: modalComponent, + title: m.duplicateAppliedControl() + }; + modalStore.trigger(modal); + } + function modalMailConfirm(id: string, name: string, action: string): void { const modalComponent: ModalComponent = { ref: ConfirmModal, @@ -329,11 +349,24 @@ {/if} {#if displayEditButton()} - {m.edit()} +
+ {m.edit()} + {#if data.urlModel === 'applied-controls'} + Power-ups: + + {/if} +
{/if} diff --git a/frontend/src/lib/components/Forms/ModelForm.svelte b/frontend/src/lib/components/Forms/ModelForm.svelte index 2a331686a..da9c227a7 100644 --- a/frontend/src/lib/components/Forms/ModelForm.svelte +++ b/frontend/src/lib/components/Forms/ModelForm.svelte @@ -51,7 +51,7 @@ export let parent: any; export let suggestions: { [key: string]: any } = {}; export let cancelButton = true; - export let riskAssessmentDuplication = false; + export let duplicate = false; const URLModel = model.urlModel as urlModel; export let schema = modelSchema(URLModel); @@ -122,7 +122,7 @@ - {#if shape.reference_control} + {#if shape.reference_control && !duplicate} {:else if URLModel === 'folders'} - {:else if URLModel === 'risk-assessments' || URLModel === 'risk-assessment-duplicate'} + {:else if URLModel === 'risk-assessments'} import AutocompleteSelect from '../AutocompleteSelect.svelte'; import Select from '../Select.svelte'; + import Checkbox from '$lib/components/Forms/Checkbox.svelte'; import TextField from '$lib/components/Forms/TextField.svelte'; import NumberField from '$lib/components/Forms/NumberField.svelte'; import { getOptions } from '$lib/utils/crud'; @@ -10,132 +11,145 @@ export let form: SuperValidated; export let model: ModelInfo; + export let duplicate: boolean = false; export let cacheLocks: Record = {}; export let formDataCache: Record = {}; export let schema: any = {}; export let initialData: Record = {}; - model.selectOptions['priority'].forEach((element) => { - element.value = parseInt(element.value); - }); + if (model.selectOptions && 'priority' in model.selectOptions) { + model.selectOptions['priority'].forEach((element) => { + element.value = parseInt(element.value); + }); + } - -{#if schema.shape.category} +{#if !duplicate} + + {#if schema.shape.category} + + + + + + + - - - - - - -; export let model: ModelInfo; - export let riskAssessmentDuplication = false; + export let duplicate = false; export let invalidateAll = true; // set to false to keep form data using muliple forms on a page export let formAction = '?/create'; export let context = 'create'; @@ -53,7 +53,7 @@ {model} {closeModal} {context} - {riskAssessmentDuplication} + {duplicate} caching={true} action={formAction} {debug} diff --git a/frontend/src/lib/utils/crud.ts b/frontend/src/lib/utils/crud.ts index 52a7e0830..20573da0b 100644 --- a/frontend/src/lib/utils/crud.ts +++ b/frontend/src/lib/utils/crud.ts @@ -5,7 +5,7 @@ import LanguageDisplay from '$lib/components/ModelTable/LanguageDisplay.svelte'; import LibraryActions from '$lib/components/ModelTable/LibraryActions.svelte'; import UserGroupNameDisplay from '$lib/components/ModelTable/UserGroupNameDisplay.svelte'; import { BASE_API_URL } from './constants'; -import type { urlModel } from './types'; +import { URL_MODEL, type urlModel } from './types'; type GetOptionsParams = { objects: any[]; @@ -282,6 +282,16 @@ export const URL_MODEL_MAP: ModelMap = { { field: 'priority' } ] }, + 'applied-controls_duplicate': { + name: 'appliedcontrol', + localName: 'appliedControl', + localNamePlural: 'appliedControls', + verboseName: 'Applied control', + verboseNamePlural: 'Applied controls', + foreignKeyFields: [ + { field: 'folder', urlModel: 'folders', urlParams: 'content_type=DO&content_type=GL' } + ] + }, policies: { name: 'appliedcontrol', localName: 'policy', @@ -767,8 +777,10 @@ export const urlParamModelSelectFields = (model: string): SelectField[] => { }; export const getModelInfo = (model: urlModel | string): ModelMapEntry => { - const map = URL_MODEL_MAP[model] || {}; - map['urlModel'] = model; + const baseModel = model.split('_')[0]; + const map = URL_MODEL_MAP[model] || URL_MODEL_MAP[baseModel] || {}; + // The urlmodel of {model}_duplicate must be {model} + map['urlModel'] = baseModel; return map; }; diff --git a/frontend/src/lib/utils/load.ts b/frontend/src/lib/utils/load.ts index 165d2a64c..08a762273 100644 --- a/frontend/src/lib/utils/load.ts +++ b/frontend/src/lib/utils/load.ts @@ -99,22 +99,24 @@ export const loadDetail = async ({ event, model, id }) => { const selectOptions: Record = {}; if (info.selectFields) { - for (const selectField of info.selectFields) { - const url = `${BASE_API_URL}/${urlModel}/${selectField.field}/`; - const response = await event.fetch(url); - if (response.ok) { - selectOptions[selectField.field] = await response.json().then((data) => - Object.entries(data).map(([key, value]) => ({ - label: value, - value: key - })) - ); - } else { - console.error( - `Failed to fetch data for ${selectField.field}: ${response.statusText}` - ); - } - } + await Promise.all( + info.selectFields.map(async (selectField) => { + const url = `${BASE_API_URL}/${urlModel}/${selectField.field}/`; + const response = await event.fetch(url); + if (response.ok) { + selectOptions[selectField.field] = await response.json().then((data) => + Object.entries(data).map(([key, value]) => ({ + label: value, + value: key + })) + ); + } else { + console.error( + `Failed to fetch data for ${selectField.field}: ${response.statusText}` + ); + } + }) + ); } relatedModels[e.urlModel] = { urlModel, diff --git a/frontend/src/lib/utils/schemas.ts b/frontend/src/lib/utils/schemas.ts index 42a10b1a6..bc96c4bb0 100644 --- a/frontend/src/lib/utils/schemas.ts +++ b/frontend/src/lib/utils/schemas.ts @@ -56,25 +56,26 @@ const nameSchema = z const descriptionSchema = z.string().optional().nullable(); -const baseNamedObject = (additionalFields: any) => - z.object({ - name: nameSchema, - description: descriptionSchema, - ...additionalFields - }); - -export const FolderSchema = baseNamedObject({ +const NameDescriptionMixin = { + name: nameSchema, + description: descriptionSchema +}; + +export const FolderSchema = z.object({ + ...NameDescriptionMixin, ref_id: z.string().optional().nullable(), parent_folder: z.string().optional() }); -export const ProjectSchema = baseNamedObject({ +export const ProjectSchema = z.object({ + ...NameDescriptionMixin, folder: z.string(), ref_id: z.string().optional().nullable(), lc_status: z.string().optional().default('in_design') }); -export const RiskMatrixSchema = baseNamedObject({ +export const RiskMatrixSchema = z.object({ + ...NameDescriptionMixin, folder: z.string(), json_definition: z.string(), is_enabled: z.boolean() @@ -84,7 +85,8 @@ export const LibraryUploadSchema = z.object({ file: z.instanceof(File).optional() }); -export const RiskAssessmentSchema = baseNamedObject({ +export const RiskAssessmentSchema = z.object({ + ...NameDescriptionMixin, version: z.string().optional().default('0.1'), project: z.string(), status: z.string().optional().nullable(), @@ -97,14 +99,16 @@ export const RiskAssessmentSchema = baseNamedObject({ observation: z.string().optional().nullable() }); -export const ThreatSchema = baseNamedObject({ +export const ThreatSchema = z.object({ + ...NameDescriptionMixin, folder: z.string(), provider: z.string().optional().nullable(), ref_id: z.string().optional().nullable(), annotation: z.string().optional().nullable() }); -export const RiskScenarioSchema = baseNamedObject({ +export const RiskScenarioSchema = z.object({ + ...NameDescriptionMixin, existing_controls: z.string().optional(), applied_controls: z.string().uuid().optional().array().optional(), existing_applied_controls: z.string().uuid().optional().array().optional(), @@ -124,7 +128,8 @@ export const RiskScenarioSchema = baseNamedObject({ ref_id: z.string().max(8).optional().nullable() }); -export const AppliedControlSchema = baseNamedObject({ +export const AppliedControlSchema = z.object({ + ...NameDescriptionMixin, ref_id: z.string().optional().nullable(), category: z.string().optional().nullable(), csf_function: z.string().optional().nullable(), @@ -142,9 +147,15 @@ export const AppliedControlSchema = baseNamedObject({ owner: z.string().uuid().optional().array().optional() }); +export const AppliedControlDuplicateSchema = z.object({ + ...AppliedControlSchema.shape, + duplicate_evidences: z.boolean() +}); + export const PolicySchema = AppliedControlSchema.omit({ category: true }); -export const RiskAcceptanceSchema = baseNamedObject({ +export const RiskAcceptanceSchema = z.object({ + ...NameDescriptionMixin, folder: z.string(), expiry_date: z.union([z.literal('').transform(() => null), z.string().date()]).nullish(), justification: z.string().optional().nullable(), @@ -152,7 +163,8 @@ export const RiskAcceptanceSchema = baseNamedObject({ risk_scenarios: z.array(z.string()) }); -export const ReferenceControlSchema = baseNamedObject({ +export const ReferenceControlSchema = z.object({ + ...NameDescriptionMixin, provider: z.string().optional().nullable(), category: z.string().optional().nullable(), csf_function: z.string().optional().nullable(), @@ -161,7 +173,8 @@ export const ReferenceControlSchema = baseNamedObject({ annotation: z.string().optional().nullable() }); -export const AssetSchema = baseNamedObject({ +export const AssetSchema = z.object({ + ...NameDescriptionMixin, business_value: z.string().optional(), type: z.string().default('PR'), folder: z.string(), @@ -241,7 +254,8 @@ export const SetPasswordSchema = z.object({ confirm_new_password: z.string() }); -export const ComplianceAssessmentSchema = baseNamedObject({ +export const ComplianceAssessmentSchema = z.object({ + ...NameDescriptionMixin, version: z.string().optional().default('0.1'), ref_id: z.string().optional().nullable(), project: z.string(), @@ -257,7 +271,8 @@ export const ComplianceAssessmentSchema = baseNamedObject({ observation: z.string().optional().nullable() }); -export const EvidenceSchema = baseNamedObject({ +export const EvidenceSchema = z.object({ + ...NameDescriptionMixin, attachment: z.any().optional().nullable(), folder: z.string(), applied_controls: z.preprocess(toArrayPreprocessor, z.array(z.string().optional())).optional(), @@ -313,13 +328,15 @@ export const SSOSettingsSchema = z.object({ want_name_id_encrypted: z.boolean().optional().nullable() }); -export const EntitiesSchema = baseNamedObject({ +export const EntitiesSchema = z.object({ + ...NameDescriptionMixin, folder: z.string(), mission: z.string().optional(), reference_link: z.string().url().optional().or(z.literal('')) }); -export const EntityAssessmentSchema = baseNamedObject({ +export const EntityAssessmentSchema = z.object({ + ...NameDescriptionMixin, create_audit: z.boolean().optional().default(false), framework: z.string().optional(), selected_implementation_groups: z.array(z.string().optional()).optional(), @@ -344,7 +361,8 @@ export const EntityAssessmentSchema = baseNamedObject({ observation: z.string().optional().nullable() }); -export const solutionSchema = baseNamedObject({ +export const solutionSchema = z.object({ + ...NameDescriptionMixin, provider_entity: z.string(), ref_id: z.string().optional(), criticality: z.number().optional() @@ -361,7 +379,8 @@ export const representativeSchema = z.object({ description: z.string().optional() }); -export const vulnerabilitySchema = baseNamedObject({ +export const vulnerabilitySchema = z.object({ + ...NameDescriptionMixin, folder: z.string(), ref_id: z.string().optional().default(''), status: z.string().default('--'), @@ -375,10 +394,10 @@ const SCHEMA_MAP: Record = { projects: ProjectSchema, 'risk-matrices': RiskMatrixSchema, 'risk-assessments': RiskAssessmentSchema, - 'risk-assessment-duplicate': RiskAssessmentSchema, threats: ThreatSchema, 'risk-scenarios': RiskScenarioSchema, 'applied-controls': AppliedControlSchema, + 'applied-controls_duplicate': AppliedControlDuplicateSchema, policies: PolicySchema, 'risk-acceptances': RiskAcceptanceSchema, 'reference-controls': ReferenceControlSchema, diff --git a/frontend/src/lib/utils/types.ts b/frontend/src/lib/utils/types.ts index 8d11ac1d0..edff27b87 100644 --- a/frontend/src/lib/utils/types.ts +++ b/frontend/src/lib/utils/types.ts @@ -26,7 +26,6 @@ export const URL_MODEL = [ 'projects', 'risk-matrices', 'risk-assessments', - 'risk-assessment-duplicate', 'threats', 'risk-scenarios', 'applied-controls', diff --git a/frontend/src/routes/(app)/(internal)/[model=urlmodel]/[id=uuid]/+page.server.ts b/frontend/src/routes/(app)/(internal)/[model=urlmodel]/[id=uuid]/+page.server.ts index 2e7ee427a..699a98520 100644 --- a/frontend/src/routes/(app)/(internal)/[model=urlmodel]/[id=uuid]/+page.server.ts +++ b/frontend/src/routes/(app)/(internal)/[model=urlmodel]/[id=uuid]/+page.server.ts @@ -1,21 +1,71 @@ import { BASE_API_URL } from '$lib/utils/constants'; import { getModelInfo, urlParamModelVerboseName } from '$lib/utils/crud'; -import { toCamelCase } from '$lib/utils/locales'; import { safeTranslate } from '$lib/utils/i18n'; import * as m from '$paraglide/messages'; import { fail, type Actions } from '@sveltejs/kit'; import { message, setError, superValidate } from 'sveltekit-superforms'; +import { setFlash } from 'sveltekit-flash-message/server'; import { zod } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; -import { nestedDeleteFormAction, nestedWriteFormAction } from '$lib/utils/actions'; +import { + nestedDeleteFormAction, + nestedWriteFormAction, + handleErrorResponse +} from '$lib/utils/actions'; +import { modelSchema } from '$lib/utils/schemas'; import { loadDetail } from '$lib/utils/load'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { - return await loadDetail({ event, model: getModelInfo(event.params.model), id: event.params.id }); + const modelInfo = getModelInfo(event.params.model + '_duplicate'); + const foreignKeys: Record = {}; + + if (modelInfo.foreignKeyFields) { + await Promise.all( + modelInfo.foreignKeyFields.map(async (keyField) => { + const queryParams = keyField.urlParams ? `?${keyField.urlParams}` : ''; + const url = `${BASE_API_URL}/${keyField.urlModel}/${queryParams}`; + const response = await event.fetch(url); + if (response.ok) { + foreignKeys[keyField.field] = await response.json().then((data) => data.results); + } else { + console.error(`Failed to fetch data for ${keyField.field}: ${response.statusText}`); + } + }) + ); + } + + modelInfo['foreignKeys'] = foreignKeys; + + const data = await loadDetail({ + event, + model: modelInfo, + id: event.params.id + }); + + if (event.params.model === 'applied-controls') { + const appliedControlSchema = modelSchema(event.params.model); + const appliedControl = data.data; + const initialDataDuplicate = { + name: appliedControl.name, + description: appliedControl.description + }; + + const appliedControlDuplicateForm = await superValidate( + initialDataDuplicate, + zod(appliedControlSchema), + { + errors: false + } + ); + + data.duplicateForm = appliedControlDuplicateForm; + } + + return data; }; export const actions: Actions = { @@ -26,6 +76,43 @@ export const actions: Actions = { delete: async (event) => { return nestedDeleteFormAction({ event }); }, + duplicate: async (event) => { + const formData = await event.request.formData(); + + if (!formData) return; + + const schema = modelSchema((event.params.model + '_duplicate') as string); + + const form = await superValidate(formData, zod(schema)); + + const endpoint = `${BASE_API_URL}/${event.params.model}/${event.params.id}/duplicate/`; + + if (!form.valid) { + console.error(form.errors); + return fail(400, { form: form }); + } + + const requestInitOptions: RequestInit = { + method: 'POST', + body: JSON.stringify(form.data) + }; + const response = await event.fetch(endpoint, requestInitOptions); + + if (!response.ok) return handleErrorResponse({ event, response, form }); + + const modelVerboseName: string = urlParamModelVerboseName(event.params.model as string); + setFlash( + { + type: 'success', + message: m.successfullyDuplicateObject({ + object: safeTranslate(modelVerboseName).toLowerCase() + }) + }, + event + ); + + return { form }; + }, reject: async ({ request, fetch, params }) => { const formData = await request.formData(); const schema = z.object({ urlmodel: z.string(), id: z.string().uuid() }); diff --git a/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+layout.server.ts b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+layout.server.ts index 67f08d3f4..f1d747965 100644 --- a/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+layout.server.ts +++ b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+layout.server.ts @@ -121,7 +121,7 @@ export const load: LayoutServerLoad = async ({ fetch, params }) => { } ); - const riskAssessmentModel = getModelInfo('risk-assessment-duplicate'); + const riskAssessmentModel = getModelInfo('risk-assessments'); if (riskAssessmentModel.foreignKeyFields) { for (const keyField of riskAssessmentModel.foreignKeyFields) { diff --git a/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+page.svelte b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+page.svelte index dd3deffbb..8286307d0 100644 --- a/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+page.svelte +++ b/frontend/src/routes/(app)/(internal)/risk-assessments/[id=uuid]/+page.svelte @@ -103,10 +103,11 @@ form: data.riskAssessmentDuplicateForm, model: data.riskAssessmentModel, debug: false, - riskAssessmentDuplication: true, + duplicate: true, formAction: '?/duplicate' } }; + const modal: ModalSettings = { type: 'component', component: modalComponent,