diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index de0e6a49dee..6726d6db2f8 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -1064,6 +1064,13 @@ def validate(self, data): if data.get("target_start") > data.get("target_end"): msg = "Your target start date exceeds your target end date" raise serializers.ValidationError(msg) + async_updating = getattr(self.instance.product, "async_updating", None) + if async_updating: # TODO: test + new_sla_config = data.get("sla_configuration", None) + old_sla_config = getattr(self.instance, "sla_configuration", None) + if new_sla_config and old_sla_config and new_sla_config != old_sla_config: + msg = "Finding SLA expiration dates are currently being recalculated. The SLA configuration for this product cannot be changed until the calculation is complete." + raise serializers.ValidationError(msg) return data def build_relational_field(self, field_name, relation_info): @@ -1408,6 +1415,16 @@ class Meta: model = Test exclude = ("inherited_tags",) + def validate(self, data): + async_updating = getattr(self.instance.engagement.product, "async_updating", None) + if async_updating: # TODO: test + new_sla_config = data.get("sla_configuration", None) + old_sla_config = getattr(self.instance, "sla_configuration", None) + if new_sla_config and old_sla_config and new_sla_config != old_sla_config: + msg = "Finding SLA expiration dates are currently being recalculated. The SLA configuration for this product cannot be changed until the calculation is complete." + raise serializers.ValidationError(msg) + return data + def build_relational_field(self, field_name, relation_info): if field_name == "notes": return NoteSerializer, {"many": True, "read_only": True} diff --git a/dojo/db_migrations/0219_engagement_sla_configuration_test_sla_configuration.py b/dojo/db_migrations/0219_engagement_sla_configuration_test_sla_configuration.py new file mode 100644 index 00000000000..1f17f58a3a1 --- /dev/null +++ b/dojo/db_migrations/0219_engagement_sla_configuration_test_sla_configuration.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.3 on 2024-11-20 08:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0218_system_settings_enforce_verified_status_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='engagement', + name='sla_configuration', + field=models.ForeignKey(blank=True, default=None, help_text='If no configuration will be configured, inherited (from product) will be applied.', null=True, on_delete=django.db.models.deletion.RESTRICT, to='dojo.sla_configuration'), + ), + migrations.AddField( + model_name='test', + name='sla_configuration', + field=models.ForeignKey(blank=True, default=None, help_text='If no configuration will be configured, inherited (from engagement) will be applied.', null=True, on_delete=django.db.models.deletion.RESTRICT, to='dojo.sla_configuration'), + ), + ] diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index 9cfab608896..273c925f370 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -82,6 +82,7 @@ Product, Product_API_Scan_Configuration, Risk_Acceptance, + SLA_Configuration, System_Settings, Test, Test_Import, @@ -277,6 +278,7 @@ def edit_engagement(request, eid): # first save engagement details new_status = form.cleaned_data.get("status") engagement.product = form.cleaned_data.get("product") + initial_sla_config = Engagement.objects.get(pk=form.instance.id).sla_configuration engagement = form.save(commit=False) if (new_status == "Cancelled" or new_status == "Completed"): engagement.active = False @@ -285,10 +287,14 @@ def edit_engagement(request, eid): engagement.save() form.save_m2m() + msg = _("Engagement updated successfully.") + # check if the SLA config was changed, append additional context to message + if initial_sla_config != form.instance.sla_configuration: # TODO: test this + msg += _(" All SLA expiration dates for findings within this product will be recalculated asynchronously for the newly assigned SLA configuration.") messages.add_message( request, messages.SUCCESS, - "Engagement updated successfully.", + msg, extra_tags="alert-success") success, jira_project_form = jira_helper.process_jira_project_form(request, instance=jira_project, target="engagement", engagement=engagement, product=engagement.product) @@ -475,6 +481,8 @@ def get(self, request, eid, *args, **kwargs): cred_eng = Cred_Mapping.objects.filter( engagement=eng.id).select_related("cred_id").order_by("cred_id") + sla = SLA_Configuration.objects.filter(id=eng.sla_configuration_id).first() + add_breadcrumb(parent=eng, top_level=False, request=request) title = "" @@ -501,6 +509,7 @@ def get(self, request, eid, *args, **kwargs): "cred_eng": cred_eng, "network": network, "preset_test_type": preset_test_type, + "sla": sla, }) def post(self, request, eid, *args, **kwargs): diff --git a/dojo/forms.py b/dojo/forms.py index d56cd1ebad2..b46fa306e17 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -313,7 +313,7 @@ class ProductForm(forms.ModelForm): queryset=Product_Type.objects.none(), required=True) - sla_configuration = forms.ModelChoiceField(label="SLA Configuration", + sla_configuration = forms.ModelChoiceField(label="SLA Configuration", # TODO: add Eng+test but is it needed? queryset=SLA_Configuration.objects.all(), required=True, initial="Default") @@ -986,6 +986,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # if this product has findings being asynchronously updated, disable the sla config field + if self.instance.product.async_updating: + self.fields["sla_configuration"].disabled = True + self.fields["sla_configuration"].widget.attrs["message"] = "Finding SLA expiration dates are currently being recalculated. " + \ + "This field cannot be changed until the calculation is complete." if product: self.fields["preset"] = forms.ModelChoiceField(help_text="Settings and notes for performing this engagement.", required=False, queryset=Engagement_Presets.objects.filter(product=product)) self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, Permissions.Product_View).filter(is_active=True) @@ -1061,6 +1066,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # if this product has findings being asynchronously updated, disable the sla config field + if self.instance.engagement.product.async_updating: + self.fields["sla_configuration"].disabled = True + self.fields["sla_configuration"].widget.attrs["message"] = "Finding SLA expiration dates are currently being recalculated. " + \ + "This field cannot be changed until the calculation is complete." + if obj: product = get_product(obj) self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, Permissions.Product_View).filter(is_active=True) @@ -1072,7 +1083,7 @@ class Meta: model = Test fields = ["title", "test_type", "target_start", "target_end", "description", "environment", "percent_complete", "tags", "lead", "version", "branch_tag", "build_id", "commit_hash", - "api_scan_configuration"] + "api_scan_configuration", "sla_configuration"] class DeleteTestForm(forms.ModelForm): diff --git a/dojo/models.py b/dojo/models.py index 61bd6622fc6..b08ef440e38 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1149,6 +1149,7 @@ class Meta: def __str__(self): return self.name + # TODO: add this to eng and test def save(self, *args, **kwargs): # get the product's sla config before saving (if this is an existing product) initial_sla_config = None @@ -1176,7 +1177,7 @@ def save(self, *args, **kwargs): super(SLA_Configuration, sla_config).save() # launch the async task to update all finding sla expiration dates from dojo.product.helpers import update_sla_expiration_dates_product_async - update_sla_expiration_dates_product_async(self, sla_config) + update_sla_expiration_dates_product_async(self, sla_config) # call the same in eng + test def get_absolute_url(self): from django.urls import reverse @@ -1488,6 +1489,12 @@ class Engagement(models.Model): source_code_management_uri = models.URLField(max_length=600, null=True, blank=True, editable=True, verbose_name=_("Repo"), help_text=_("Resource link to source code")) orchestration_engine = models.ForeignKey(Tool_Configuration, verbose_name=_("Orchestration Engine"), help_text=_("Orchestration service responsible for CI/CD test"), null=True, blank=True, related_name="orchestration", on_delete=models.CASCADE) deduplication_on_engagement = models.BooleanField(default=False, verbose_name=_("Deduplication within this engagement only"), help_text=_("If enabled deduplication will only mark a finding in this engagement as duplicate of another finding if both findings are in this engagement. If disabled, deduplication is on the product level.")) + sla_configuration = models.ForeignKey(SLA_Configuration, + null=True, + blank=True, + default=None, + on_delete=models.RESTRICT, + help_text=_("If no configuration will be configured, inherited (from product) will be applied.")) tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this engagement. Choose from the list or add new tags. Press Enter key to add.")) inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) @@ -2046,7 +2053,7 @@ class Meta: ordering = ("-created", ) -class Test(models.Model): +class Test(models.Model): engagement = models.ForeignKey(Engagement, editable=False, on_delete=models.CASCADE) lead = models.ForeignKey(Dojo_User, editable=True, null=True, blank=True, on_delete=models.RESTRICT) test_type = models.ForeignKey(Test_Type, on_delete=models.CASCADE) @@ -2080,6 +2087,12 @@ class Test(models.Model): branch_tag = models.CharField(editable=True, max_length=150, null=True, blank=True, help_text=_("Tag or branch that was tested, a reimport may update this field."), verbose_name=_("Branch/Tag")) api_scan_configuration = models.ForeignKey(Product_API_Scan_Configuration, null=True, editable=True, blank=True, on_delete=models.CASCADE, verbose_name=_("API Scan Configuration")) + sla_configuration = models.ForeignKey(SLA_Configuration, + null=True, + blank=True, + default=None, + on_delete=models.RESTRICT, + help_text=_("If no configuration will be configured, inherited (from engagement) will be applied.")) class Meta: indexes = [ @@ -3015,7 +3028,12 @@ def get_sla_start_date(self): return self.date def get_sla_period(self): - sla_configuration = SLA_Configuration.objects.filter(id=self.test.engagement.product.sla_configuration_id).first() + # TODO: test this + sla_configuration = SLA_Configuration.objects.filter(id=self.test.sla_configuration_id).first() + if not sla_configuration: + sla_configuration = SLA_Configuration.objects.filter(id=self.test.engagement.sla_configuration_id).first() + if not sla_configuration: + sla_configuration = SLA_Configuration.objects.filter(id=self.test.engagement.product.sla_configuration_id).first() sla_period = getattr(sla_configuration, self.severity.lower(), None) enforce_period = getattr(sla_configuration, str("enforce_" + self.severity.lower()), None) return sla_period, enforce_period diff --git a/dojo/product/views.py b/dojo/product/views.py index 7db5b47b56b..f3ab0be53b0 100644 --- a/dojo/product/views.py +++ b/dojo/product/views.py @@ -950,13 +950,13 @@ def edit_product(request, pid): if form.is_valid(): initial_sla_config = Product.objects.get(pk=form.instance.id).sla_configuration form.save() - msg = "Product updated successfully." + msg = _("Product updated successfully.") # check if the SLA config was changed, append additional context to message if initial_sla_config != form.instance.sla_configuration: - msg += " All SLA expiration dates for findings within this product will be recalculated asynchronously for the newly assigned SLA configuration." + msg += _(" All SLA expiration dates for findings within this product will be recalculated asynchronously for the newly assigned SLA configuration.") messages.add_message(request, messages.SUCCESS, - _(msg), + msg, extra_tags="alert-success") success, jform = jira_helper.process_jira_project_form(request, instance=jira_project, product=product) diff --git a/dojo/sla_config/helpers.py b/dojo/sla_config/helpers.py index 9d76faaa061..da11127772e 100644 --- a/dojo/sla_config/helpers.py +++ b/dojo/sla_config/helpers.py @@ -17,7 +17,7 @@ def update_sla_expiration_dates_sla_config_sync(sla_config, severities, products logger.info(f"Updating finding SLA expiration dates within the {sla_config} SLA configuration") # update each finding that is within the SLA configuration that was saved for f in Finding.objects.filter(test__engagement__product__sla_configuration_id=sla_config.id, severity__in=severities): - f.save() + f.save() # TODO: How to handle this? # reset the async updating flag to false for all products using this sla config for product in products: product.async_updating = False diff --git a/dojo/sla_config/views.py b/dojo/sla_config/views.py index c07e8dadc2a..4a9a1e38359 100644 --- a/dojo/sla_config/views.py +++ b/dojo/sla_config/views.py @@ -41,7 +41,7 @@ def edit_sla_config(request, slaid): if request.method == "POST" and request.POST.get("delete"): if sla_config.id != 1: - if Product.objects.filter(sla_configuration=sla_config).count(): + if Product.objects.filter(sla_configuration=sla_config).count(): # TODO: Or eng, or test msg = f'The "{sla_config}" SLA configuration could not be deleted, as it is currently in use by one or more products.' messages.add_message(request, messages.ERROR, diff --git a/dojo/templates/dojo/view_eng.html b/dojo/templates/dojo/view_eng.html index 7aa7d631d3e..27b9254a5c9 100644 --- a/dojo/templates/dojo/view_eng.html +++ b/dojo/templates/dojo/view_eng.html @@ -921,6 +921,64 @@