Skip to content

Commit 6e3b004

Browse files
feat: sync applied controls to reference control (#3600)
* Feat: sync applied controls to reference control * Code corrections * Deduce good skip_sync value * Sync applied control to their reference control * Implement dry run + better UX * Formatter * Fix zod4 migration * update emoji --------- Co-authored-by: eric-intuitem <71850047+eric-intuitem@users.noreply.github.com>
1 parent d8667ed commit 6e3b004

34 files changed

Lines changed: 590 additions & 45 deletions

File tree

backend/core/models.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from django.core.files.storage import default_storage
2727
from django.db import models, transaction
2828
from django.db.models import F, Q, OuterRef, Subquery, Prefetch, Count
29+
from django.db.models.query import QuerySet
2930
from django.forms.models import model_to_dict
3031
from django.urls import reverse
3132
from django.utils.html import format_html
@@ -2190,6 +2191,15 @@ def is_deletable(self) -> bool:
21902191
def frameworks(self):
21912192
return Framework.objects.filter(requirement__reference_controls=self).distinct()
21922193

2194+
def get_unsynced_applied_controls_queryset(self) -> QuerySet[AppliedControl]:
2195+
"""Return a `QuerySet` selecting all `AppliedControl` objects linked to this `ReferenceControl` which are not currently synced to it."""
2196+
2197+
unsynced_applied_controls_query = self.appliedcontrol_set.exclude(
2198+
csf_function=self.csf_function,
2199+
category=self.category,
2200+
)
2201+
return unsynced_applied_controls_query
2202+
21932203

21942204
class RiskMatrix(ReferentialObjectMixin, I18nObjectMixin, EditableMixin):
21952205
library = models.ForeignKey(
@@ -4797,7 +4807,18 @@ class Status(models.TextChoices):
47974807

47984808
IMPACT = [(1, "Very Low"), (2, "Low"), (3, "Medium"), (4, "High"), (5, "Very High")]
47994809
MAP_EFFORT = {None: -1, "XS": 1, "S": 2, "M": 3, "L": 4, "XL": 5}
4800-
# todo: think about a smarter model for ranking
4810+
4811+
INTEGRATION_SYNCABLE_FIELDS: Final[set[str]] = {
4812+
"name",
4813+
"description",
4814+
"status",
4815+
"priority",
4816+
"eta",
4817+
"start_date",
4818+
"effort",
4819+
"observation",
4820+
}
4821+
48014822
reference_control = models.ForeignKey(
48024823
ReferenceControl,
48034824
on_delete=models.CASCADE,
@@ -4988,22 +5009,11 @@ def save(self, *args, **kwargs):
49885009

49895010
BuiltinMetricSample.update_or_create_snapshot(self.folder)
49905011

4991-
def _get_changed_fields(self, old_instance):
5012+
def _get_changed_fields(self, old_instance) -> list[str]:
49925013
"""Detect which fields changed"""
49935014
changed = []
4994-
# Check syncable fields only
4995-
syncable_fields = [
4996-
"name",
4997-
"description",
4998-
"status",
4999-
"priority",
5000-
"eta",
5001-
"start_date",
5002-
"effort",
5003-
"observation",
5004-
]
50055015

5006-
for field in syncable_fields:
5016+
for field in self.INTEGRATION_SYNCABLE_FIELDS:
50075017
old_val = getattr(old_instance, field)
50085018
new_val = getattr(self, field)
50095019
if old_val != new_val:

backend/core/views.py

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import zipfile
1717
import tempfile
1818
from datetime import date, datetime, timedelta
19-
from typing import Dict, Any, List, Tuple
19+
from typing import Dict, Any, List, Tuple, Final
2020
import time
2121
from django.db.models import (
2222
F,
@@ -39,7 +39,7 @@
3939
QuerySet,
4040
Prefetch,
4141
)
42-
from django.db.models.functions import Greatest, Coalesce
42+
from django.db.models.functions import Coalesce
4343

4444
from collections import defaultdict
4545
import pytz
@@ -76,10 +76,11 @@
7676

7777

7878
from django.apps import apps
79-
from django.contrib.auth.models import Permission
79+
from django.contrib.auth.models import AnonymousUser, Permission
8080
from django.conf import settings
8181
from django.core.exceptions import FieldDoesNotExist
8282
from django.core.files.storage import default_storage
83+
from django.contrib.auth.base_user import AbstractBaseUser
8384

8485
from django.db import models, transaction
8586
from django.forms import ValidationError
@@ -2434,6 +2435,75 @@ def category(self, request):
24342435
def csf_function(self, request):
24352436
return Response(dict(ReferenceControl.CSF_FUNCTION))
24362437

2438+
@staticmethod
2439+
def _get_syncable_applied_controls(
2440+
reference_control: ReferenceControl, user: AbstractBaseUser | AnonymousUser
2441+
) -> list[AppliedControl]:
2442+
"""Return the list of syncable `AppliedControl` objects (meaning they are currently unsynced) the `User` can synchronize (based on his permissions)."""
2443+
2444+
_, changeable_applied_controls, _ = RoleAssignment.get_accessible_object_ids(
2445+
Folder.get_root_folder(), user, AppliedControl
2446+
)
2447+
2448+
syncable_applied_controls = (
2449+
reference_control.get_unsynced_applied_controls_queryset().filter(
2450+
id__in=changeable_applied_controls,
2451+
)
2452+
)
2453+
return list(syncable_applied_controls)
2454+
2455+
@action(detail=True, methods=["get"], url_path="syncable-applied-controls")
2456+
def syncable_applied_controls(self, request, pk):
2457+
reference_control = self.get_object()
2458+
syncable_applied_controls = self._get_syncable_applied_controls(
2459+
reference_control, request.user
2460+
)
2461+
2462+
return Response(
2463+
[
2464+
{"id": applied_control.id, "name": applied_control.name}
2465+
for applied_control in syncable_applied_controls
2466+
]
2467+
)
2468+
2469+
@action(detail=True, methods=["post"], url_path="sync-applied-controls")
2470+
def sync_applied_controls(self, request, pk):
2471+
reference_control = self.get_object()
2472+
syncable_applied_controls = self._get_syncable_applied_controls(
2473+
reference_control, request.user
2474+
)
2475+
2476+
FIELDS_TO_SYNC: Final[list[str]] = [
2477+
"category",
2478+
"csf_function",
2479+
]
2480+
2481+
for syncable_applied_control in syncable_applied_controls:
2482+
for field_to_sync in FIELDS_TO_SYNC:
2483+
reference_control_value = getattr(reference_control, field_to_sync)
2484+
2485+
setattr(
2486+
syncable_applied_control, field_to_sync, reference_control_value
2487+
)
2488+
2489+
AppliedControl.objects.bulk_update(
2490+
syncable_applied_controls, FIELDS_TO_SYNC, batch_size=100
2491+
)
2492+
2493+
skip_sync = all(
2494+
field_to_sync not in AppliedControl.INTEGRATION_SYNCABLE_FIELDS
2495+
for field_to_sync in FIELDS_TO_SYNC
2496+
)
2497+
for applied_control in syncable_applied_controls:
2498+
applied_control.save(skip_sync=skip_sync)
2499+
2500+
return Response(
2501+
[
2502+
{"id": applied_control.id, "name": applied_control.name}
2503+
for applied_control in syncable_applied_controls
2504+
]
2505+
)
2506+
24372507

24382508
class RiskMatrixViewSet(BaseModelViewSet):
24392509
"""
@@ -5649,6 +5719,41 @@ def build_sunburst_data(data_dict, name="Root", level=0, parent_color=None):
56495719

56505720
return Response({"results": sunburst_data})
56515721

5722+
@action(detail=True, methods=["post"], url_path="sync-to-reference-control")
5723+
def sync_applied_controls(self, request, pk):
5724+
dry_run = request.query_params.get("dry_run", True)
5725+
if dry_run == "false":
5726+
dry_run = False
5727+
5728+
applied_control = self.get_object()
5729+
reference_control = applied_control.reference_control
5730+
changes: list[tuple[str, str]] = [] # List of (old_value, new_value) tuples.
5731+
5732+
FIELDS_TO_SYNC: Final[list[str]] = [
5733+
"category",
5734+
"csf_function",
5735+
]
5736+
5737+
for field_to_sync in FIELDS_TO_SYNC:
5738+
reference_control_value = getattr(reference_control, field_to_sync)
5739+
5740+
applied_control_value = getattr(applied_control, field_to_sync)
5741+
if reference_control_value != applied_control_value:
5742+
changes.append((reference_control_value, applied_control_value))
5743+
5744+
setattr(applied_control, field_to_sync, reference_control_value)
5745+
5746+
if dry_run:
5747+
return Response(changes)
5748+
5749+
skip_sync = all(
5750+
field_to_sync not in AppliedControl.INTEGRATION_SYNCABLE_FIELDS
5751+
for field_to_sync in FIELDS_TO_SYNC
5752+
)
5753+
applied_control.save(update_fields=FIELDS_TO_SYNC, skip_sync=skip_sync)
5754+
5755+
return Response(changes)
5756+
56525757

56535758
class ActionPlanList(generics.ListAPIView):
56545759
search_fields = ["name", "description", "ref_id"]

frontend/messages/ar.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1106,5 +1106,9 @@
11061106
"serverConfigurationError": "Server configuration error",
11071107
"libraryLoadFailed": "Library load failed",
11081108
"sortAscending": "ترتيب أ ← ي",
1109-
"sortDescending": "ترتيب ي ← أ"
1109+
"sortDescending": "ترتيب ي ← أ",
1110+
"confirmModalMessagePlural": "هل أنت متأكد؟ سيؤثر هذا الإجراء بشكل دائم على الكائنات التالية",
1111+
"syncAppliedControlsMessage": "سيتم مزامنة عناصر التحكم المطبقة التي تستخدم عنصر التحكم المرجعي هذا معه. يرجى ملاحظة أن هذا الإجراء لا يمكن التراجع عنه.",
1112+
"syncToAppliedControl": "مزامنة مع التحكم المطبق",
1113+
"syncToReferenceControl": "مزامنة مع التحكم المرجعي"
11101114
}

frontend/messages/cs.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1126,5 +1126,9 @@
11261126
"serverConfigurationError": "Server configuration error",
11271127
"libraryLoadFailed": "Library load failed",
11281128
"sortAscending": "Řadit A → Z",
1129-
"sortDescending": "Řadit Z → A"
1129+
"sortDescending": "Řadit Z → A",
1130+
"confirmModalMessagePlural": "Jste si jistí? Tato akce trvale ovlivní následující objekty",
1131+
"syncAppliedControlsMessage": "Použité kontroly využívající tuto referenční kontrolu s ní budou synchronizovány. Upozorňujeme, že tuto akci nelze vrátit zpět.",
1132+
"syncToAppliedControl": "Synchronizovat s aplikovanou kontrolou",
1133+
"syncToReferenceControl": "Synchronizovat s referenční kontrolou"
11301134
}

frontend/messages/da.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1420,5 +1420,9 @@
14201420
"serverConfigurationError": "Server configuration error",
14211421
"libraryLoadFailed": "Library load failed",
14221422
"sortAscending": "Sorter A → Z",
1423-
"sortDescending": "Sorter Z → A"
1423+
"sortDescending": "Sorter Z → A",
1424+
"confirmModalMessagePlural": "Er du sikker? Denne handling vil permanent påvirke følgende objekter",
1425+
"syncAppliedControlsMessage": "De anvendte kontroller, der bruger denne referencekontrol, vil blive synkroniseret med den. Bemærk venligst, at dette ikke kan fortrydes.",
1426+
"syncToAppliedControl": "Synkroniser med anvendt kontrol",
1427+
"syncToReferenceControl": "Synkroniser med referencekontrol"
14241428
}

frontend/messages/de.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@
488488
"revoke": "Widerrufen",
489489
"riskAcceptanceValidatedMessage": "Diese Risikoakzeptanz ist validiert. Sie kann widerrufen werden, jedoch nicht rückgängig gemacht werden. Erstellen Sie ggf. eine neue Version.",
490490
"confirmModalTitle": "Bestätigen",
491-
"confirmModalMessage": "Sind Sie sicher? Diese Aktion wirkt sich dauerhaft auf folgendes Objekt aus:",
491+
"confirmModalMessage": "Sind Sie sicher? Diese Aktion wirkt sich dauerhaft auf folgendes Objekt aus",
492492
"submit": "Einreichen",
493493
"requirementAssessment": "Anforderungsbewertung",
494494
"requirementAssessments": "Anforderungsbewertungen",
@@ -3013,5 +3013,9 @@
30133013
"serverConfigurationError": "Serverkonfigurationsfehler",
30143014
"libraryLoadFailed": "Bibliothek konnte nicht geladen werden",
30153015
"sortAscending": "Sortieren A → Z",
3016-
"sortDescending": "Sortieren Z → A"
3016+
"sortDescending": "Sortieren Z → A",
3017+
"confirmModalMessagePlural": "Sind Sie sicher? Diese Aktion wirkt sich dauerhaft auf folgende Objekte aus",
3018+
"syncAppliedControlsMessage": "Die angewendeten Kontrollen, die diese Referenzkontrolle verwenden, werden mit ihr synchronisiert. Bitte beachten Sie, dass dies nicht rückgängig gemacht werden kann.",
3019+
"syncToAppliedControl": "Mit angewendeter Maßnahme synchronisieren",
3020+
"syncToReferenceControl": "Mit Referenzkontrolle synchronisieren"
30173021
}

frontend/messages/el.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1837,5 +1837,9 @@
18371837
"serverConfigurationError": "Server configuration error",
18381838
"libraryLoadFailed": "Library load failed",
18391839
"sortAscending": "Ταξινόμηση Α → Ω",
1840-
"sortDescending": "Ταξινόμηση Ω → Α"
1840+
"sortDescending": "Ταξινόμηση Ω → Α",
1841+
"confirmModalMessagePlural": "Είστε σίγουροι; Αυτή η ενέργεια θα επηρεάσει μόνιμα τα ακόλουθα αντικείμενα",
1842+
"syncAppliedControlsMessage": "Οι εφαρμοζόμενοι έλεγχοι που χρησιμοποιούν αυτόν τον πρότυπο έλεγχο θα συγχρονιστούν με αυτόν. Λάβετε υπόψη ότι αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
1843+
"syncToAppliedControl": "Συγχρονισμός με τον εφαρμοζόμενο έλεγχο",
1844+
"syncToReferenceControl": "Συγχρονισμός με τον πρότυπο έλεγχο"
18411845
}

frontend/messages/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1436,6 +1436,7 @@
14361436
"selectRequirementsToAssign": "Select requirements to include in a new assignment",
14371437
"makeSureActorsHavePermissions": "Make sure that the assigned users have access to the domain of this audit",
14381438
"selectAllAvailable": "Select all available",
1439+
"clearSelection": "Clear selection",
14391440
"expandAll": "Expand all",
14401441
"collapseAll": "Collapse all",
14411442
"clickToSelect": "click to select",
@@ -2287,7 +2288,6 @@
22872288
"changeTreatment": "Change treatment",
22882289
"changeAssignee": "Change assignee",
22892290
"changeResult": "Change result",
2290-
"clearSelection": "Clear selection",
22912291
"itemsSelected": "{count} item(s) selected",
22922292
"batchActionConfirmDelete": "Are you sure you want to delete {count} item(s)?",
22932293
"batchActionConfirmChange": "Apply to {count} item(s):",
@@ -3857,6 +3857,8 @@
38573857
"showProcessed": "Show processed ({count})",
38583858
"hideProcessed": "Hide processed",
38593859
"ctrlEnterToPost": "Ctrl+Enter to post",
3860+
"confirmModalMessagePlural": "Are you sure? This action will permanently affect the following objects",
3861+
"syncAppliedControlsMessage": "The applied controls using this reference control will be synced to it. Please note that this cannot be undone.",
38603862
"presets": "Journeys",
38613863
"presetsDescription": "Follow guided journeys to set up your security program step by step. You can also explore the platform freely and unlock all capabilities from the settings.",
38623864
"availablePresets": "Available Templates",
@@ -3989,6 +3991,9 @@
39893991
"addLanguage": "Add language",
39903992
"untitledMatrix": "Untitled Matrix",
39913993
"closeEditor": "Close editor",
3994+
"removeLanguageConfirm": "Remove {lang} translations?",
3995+
"syncToAppliedControl": "Sync to applied control",
3996+
"syncToReferenceControl": "Sync to reference control",
39923997
"statementOfApplicability": "Statement of Applicability",
39933998
"soaDescription": "Generate a Statement of Applicability from a compliance assessment and optionally enrich it with risk assessment data",
39943999
"soaSelectCompliance": "Select a compliance assessment",

frontend/messages/es.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3154,5 +3154,9 @@
31543154
"serverConfigurationError": "Error de configuración del servidor",
31553155
"libraryLoadFailed": "Error al cargar la biblioteca",
31563156
"sortAscending": "Ordenar A → Z",
3157-
"sortDescending": "Ordenar Z → A"
3157+
"sortDescending": "Ordenar Z → A",
3158+
"confirmModalMessagePlural": "¿Estás seguro? Esta acción afectará permanentemente a los siguientes objetos",
3159+
"syncAppliedControlsMessage": "Los controles aplicados que utilizan este control de referencia se sincronizarán con él. Tenga en cuenta que esto no se puede deshacer.",
3160+
"syncToAppliedControl": "Sincronizar con el control aplicado",
3161+
"syncToReferenceControl": "Sincronizar con el control de referencia"
31583162
}

frontend/messages/fr.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,7 @@
13131313
"selectRequirementsToAssign": "Sélectionnez les exigences à inclure dans une nouvelle affectation",
13141314
"makeSureActorsHavePermissions": "Assurez-vous que les personnes assignées disposent des droits sur le domaine de cet audit",
13151315
"selectAllAvailable": "Tout sélectionner",
1316+
"clearSelection": "Effacer la sélection",
13161317
"expandAll": "Tout déplier",
13171318
"collapseAll": "Tout replier",
13181319
"clickToSelect": "cliquer pour sélectionner",
@@ -2170,7 +2171,6 @@
21702171
"changeTreatment": "Changer le traitement",
21712172
"changeAssignee": "Changer le responsable",
21722173
"changeResult": "Changer le résultat",
2173-
"clearSelection": "Effacer la sélection",
21742174
"itemsSelected": "{count} élément(s) sélectionné(s)",
21752175
"batchActionConfirmDelete": "Êtes-vous sûr de vouloir supprimer {count} élément(s) ?",
21762176
"batchActionConfirmChange": "Appliquer à {count} élément(s) :",
@@ -3721,6 +3721,8 @@
37213721
"libraryLoadFailed": "Échec du chargement de la bibliothèque",
37223722
"sortAscending": "Trier A → Z",
37233723
"sortDescending": "Trier Z → A",
3724+
"confirmModalMessagePlural": "Êtes-vous sûr ? Cette action affectera définitivement les objets suivants",
3725+
"syncAppliedControlsMessage": "Les mesures appliquées utilisant cette mesure de référence seront synchronisées avec celle-ci. Cette opération ne pourrait pas être annulée.",
37243726
"assignmentStatusDraft": "Brouillon",
37253727
"assignmentStatusInProgress": "En cours",
37263728
"assignmentStatusSubmitted": "Soumis",
@@ -3954,6 +3956,8 @@
39543956
"chatRetry": "Réessayer",
39553957
"chatNoConversations": "Aucune conversation",
39563958
"chatDisclaimer": "L'IA peut se tromper. Les données sont envoyées uniquement à votre fournisseur IA configuré.",
3959+
"syncToAppliedControl": "Synchroniser avec la mesure appliquée",
3960+
"syncToReferenceControl": "Synchroniser avec la mesure de référence",
39573961
"riskCoverage": "Couverture des risques",
39583962
"soaSelectRiskDescription": "La s\u00e9lection d\u2019analyses de risques enrichira la DdA avec les sc\u00e9narios de risques et les d\u00e9cisions de traitement associ\u00e9s"
39593963
}

0 commit comments

Comments
 (0)