Skip to content

Commit 63eff98

Browse files
committed
Move Risk Acceptance from Engagement to Product level
- Changed Risk_Acceptance relationship from Engagement (ManyToMany) to Product (ForeignKey) - Created dedicated risk_acceptance module separate from engagement - Simplified URLs to use only risk acceptance ID without requiring product/engagement ID - Added dedicated Product-level Risk Acceptance UI page with navigation tab - Created 3 separate migration files (0249, 0250, 0251) - Updated all templates, views, API serializers, and tests - Adapted to upstream/dev structure with asset/urls.py instead of product/urls.py - Fixed function name from _copy_model_util to copy_model_util for upstream/dev compatibility Signed-off-by: kiblik <[email protected]>
1 parent a5dc944 commit 63eff98

31 files changed

+864
-718
lines changed

dojo/api_v2/serializers.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,33 +1537,27 @@ def update(self, instance, validated_data):
15371537

15381538
@extend_schema_field(serializers.CharField())
15391539
def get_path(self, obj):
1540-
engagement = Engagement.objects.filter(
1541-
risk_acceptance__id__in=[obj.id],
1542-
).first()
15431540
path = "No proof has been supplied"
1544-
if engagement and obj.filename() is not None:
1541+
if obj.product and obj.filename() is not None:
15451542
path = reverse(
1546-
"download_risk_acceptance", args=(engagement.id, obj.id),
1543+
"download_risk_acceptance", args=(obj.id,),
15471544
)
15481545
request = self.context.get("request")
15491546
if request:
15501547
path = request.build_absolute_uri(path)
15511548
return path
15521549

15531550
@extend_schema_field(serializers.IntegerField())
1554-
def get_engagement(self, obj):
1555-
engagement = Engagement.objects.filter(
1556-
risk_acceptance__id__in=[obj.id],
1557-
).first()
1558-
return EngagementSerializer(read_only=True).to_representation(
1559-
engagement,
1551+
def get_product(self, obj):
1552+
return ProductSerializer(read_only=True).to_representation(
1553+
obj.product,
15601554
)
15611555

15621556
def validate(self, data):
1563-
def validate_findings_have_same_engagement(finding_objects: list[Finding]):
1564-
engagements = finding_objects.values_list("test__engagement__id", flat=True).distinct().count()
1565-
if engagements > 1:
1566-
msg = "You are not permitted to add findings from multiple engagements"
1557+
def validate_findings_have_same_product(finding_objects: list[Finding]):
1558+
products = finding_objects.values_list("test__engagement__product__id", flat=True).distinct().count()
1559+
if products > 1:
1560+
msg = "You are not permitted to add findings from multiple products"
15671561
raise PermissionDenied(msg)
15681562

15691563
findings = data.get("accepted_findings", [])
@@ -1574,11 +1568,11 @@ def validate_findings_have_same_engagement(finding_objects: list[Finding]):
15741568
msg = "You are not permitted to add one or more selected findings to this risk acceptance"
15751569
raise PermissionDenied(msg)
15761570
if self.context["request"].method == "POST":
1577-
validate_findings_have_same_engagement(finding_objects)
1571+
validate_findings_have_same_product(finding_objects)
15781572
elif self.context["request"].method in {"PATCH", "PUT"}:
15791573
existing_findings = Finding.objects.filter(risk_acceptance=self.instance.id)
15801574
existing_and_new_findings = existing_findings | finding_objects
1581-
validate_findings_have_same_engagement(existing_and_new_findings)
1575+
validate_findings_have_same_product(existing_and_new_findings)
15821576
return data
15831577

15841578
class Meta:

dojo/api_v2/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ def get_queryset(self):
722722
return (
723723
get_authorized_risk_acceptances(Permissions.Risk_Acceptance)
724724
.prefetch_related(
725-
"notes", "engagement_set", "owner", "accepted_findings",
725+
"notes", "product", "owner", "accepted_findings",
726726
)
727727
.distinct()
728728
)

dojo/asset/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
views.view_product_components,
2424
name="view_product_components",
2525
),
26+
re_path(
27+
r"^asset/(?P<pid>\d+)/risk_acceptance$",
28+
views.view_product_risk_acceptances,
29+
name="view_product_risk_acceptances",
30+
),
2631
re_path(
2732
r"^asset/(?P<pid>\d+)/engagements$",
2833
views.view_engagements,
@@ -177,6 +182,7 @@
177182
re_path(r"^product$", redirect_view("product")),
178183
re_path(r"^product/(?P<pid>\d+)$", redirect_view("view_product")),
179184
re_path(r"^product/(?P<pid>\d+)/components$", redirect_view("view_product_components")),
185+
re_path(r"^product/(?P<pid>\d+)/risk_acceptance$", redirect_view("view_product_risk_acceptances")),
180186
re_path(r"^product/(?P<pid>\d+)/engagements$", redirect_view("view_engagements")),
181187
re_path(r"^product/(?P<product_id>\d+)/import_scan_results$", redirect_view("import_scan_results_prod")),
182188
re_path(r"^product/(?P<pid>\d+)/metrics$", redirect_view("view_product_metrics")),
@@ -214,6 +220,8 @@
214220
name="view_product"),
215221
re_path(r"^product/(?P<pid>\d+)/components$", views.view_product_components,
216222
name="view_product_components"),
223+
re_path(r"^product/(?P<pid>\d+)/risk_acceptance$", views.view_product_risk_acceptances,
224+
name="view_product_risk_acceptances"),
217225
re_path(r"^product/(?P<pid>\d+)/engagements$", views.view_engagements,
218226
name="view_engagements"),
219227
re_path(
@@ -283,6 +291,7 @@
283291
re_path(r"^asset$", redirect_view("product")),
284292
re_path(r"^asset/(?P<pid>\d+)$", redirect_view("view_product")),
285293
re_path(r"^asset/(?P<pid>\d+)/components$", redirect_view("view_product_components")),
294+
re_path(r"^asset/(?P<pid>\d+)/risk_acceptance$", redirect_view("view_product_risk_acceptances")),
286295
re_path(r"^asset/(?P<pid>\d+)/engagements$", redirect_view("view_engagements")),
287296
re_path(r"^asset/(?P<product_id>\d+)/import_scan_results$", redirect_view("import_scan_results_prod")),
288297
re_path(r"^asset/(?P<pid>\d+)/metrics$", redirect_view("view_product_metrics")),
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Generated migration - Step 1: Add product field to Risk_Acceptance
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import pgtrigger
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('dojo', '0248_alter_general_survey_expiration'),
12+
]
13+
14+
operations = [
15+
# Add product field (nullable initially so we can populate it)
16+
migrations.AddField(
17+
model_name='risk_acceptance',
18+
name='product',
19+
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='risk_acceptances', to='dojo.product'),
20+
),
21+
migrations.AddField(
22+
model_name='risk_acceptanceevent',
23+
name='product',
24+
field=models.ForeignKey(db_constraint=False, db_index=False, editable=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='dojo.product'),
25+
),
26+
pgtrigger.migrations.RemoveTrigger(
27+
model_name='risk_acceptance',
28+
name='insert_insert',
29+
),
30+
pgtrigger.migrations.RemoveTrigger(
31+
model_name='risk_acceptance',
32+
name='update_update',
33+
),
34+
pgtrigger.migrations.RemoveTrigger(
35+
model_name='risk_acceptance',
36+
name='delete_delete',
37+
),
38+
pgtrigger.migrations.AddTrigger(
39+
model_name='risk_acceptance',
40+
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_risk_acceptanceevent" ("accepted_by", "created", "decision", "decision_details", "expiration_date", "expiration_date_handled", "expiration_date_warned", "id", "name", "owner_id", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "product_id", "reactivate_expired", "recommendation", "recommendation_details", "restart_sla_expired", "updated") VALUES (NEW."accepted_by", NEW."created", NEW."decision", NEW."decision_details", NEW."expiration_date", NEW."expiration_date_handled", NEW."expiration_date_warned", NEW."id", NEW."name", NEW."owner_id", NEW."path", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."product_id", NEW."reactivate_expired", NEW."recommendation", NEW."recommendation_details", NEW."restart_sla_expired", NEW."updated"); RETURN NULL;', hash='83d5189fd3362f9e91757621240964180e09bf95', operation='INSERT', pgid='pgtrigger_insert_insert_d29bd', table='dojo_risk_acceptance', when='AFTER')),
41+
),
42+
pgtrigger.migrations.AddTrigger(
43+
model_name='risk_acceptance',
44+
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."accepted_by" IS DISTINCT FROM (NEW."accepted_by") OR OLD."decision" IS DISTINCT FROM (NEW."decision") OR OLD."decision_details" IS DISTINCT FROM (NEW."decision_details") OR OLD."expiration_date" IS DISTINCT FROM (NEW."expiration_date") OR OLD."expiration_date_handled" IS DISTINCT FROM (NEW."expiration_date_handled") OR OLD."expiration_date_warned" IS DISTINCT FROM (NEW."expiration_date_warned") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."name" IS DISTINCT FROM (NEW."name") OR OLD."owner_id" IS DISTINCT FROM (NEW."owner_id") OR OLD."path" IS DISTINCT FROM (NEW."path") OR OLD."product_id" IS DISTINCT FROM (NEW."product_id") OR OLD."reactivate_expired" IS DISTINCT FROM (NEW."reactivate_expired") OR OLD."recommendation" IS DISTINCT FROM (NEW."recommendation") OR OLD."recommendation_details" IS DISTINCT FROM (NEW."recommendation_details") OR OLD."restart_sla_expired" IS DISTINCT FROM (NEW."restart_sla_expired"))', func='INSERT INTO "dojo_risk_acceptanceevent" ("accepted_by", "created", "decision", "decision_details", "expiration_date", "expiration_date_handled", "expiration_date_warned", "id", "name", "owner_id", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "product_id", "reactivate_expired", "recommendation", "recommendation_details", "restart_sla_expired", "updated") VALUES (NEW."accepted_by", NEW."created", NEW."decision", NEW."decision_details", NEW."expiration_date", NEW."expiration_date_handled", NEW."expiration_date_warned", NEW."id", NEW."name", NEW."owner_id", NEW."path", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."product_id", NEW."reactivate_expired", NEW."recommendation", NEW."recommendation_details", NEW."restart_sla_expired", NEW."updated"); RETURN NULL;', hash='6e5515509e5c952f582b91b5ac3aa7f5bed0f727', operation='UPDATE', pgid='pgtrigger_update_update_55e64', table='dojo_risk_acceptance', when='AFTER')),
45+
),
46+
pgtrigger.migrations.AddTrigger(
47+
model_name='risk_acceptance',
48+
trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_risk_acceptanceevent" ("accepted_by", "created", "decision", "decision_details", "expiration_date", "expiration_date_handled", "expiration_date_warned", "id", "name", "owner_id", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "product_id", "reactivate_expired", "recommendation", "recommendation_details", "restart_sla_expired", "updated") VALUES (OLD."accepted_by", OLD."created", OLD."decision", OLD."decision_details", OLD."expiration_date", OLD."expiration_date_handled", OLD."expiration_date_warned", OLD."id", OLD."name", OLD."owner_id", OLD."path", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."product_id", OLD."reactivate_expired", OLD."recommendation", OLD."recommendation_details", OLD."restart_sla_expired", OLD."updated"); RETURN NULL;', hash='68cfbb774b18823b974228d517729985c0087130', operation='DELETE', pgid='pgtrigger_delete_delete_7d103', table='dojo_risk_acceptance', when='AFTER')),
49+
),
50+
]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Generated migration - Step 2: Migrate Risk_Acceptance data from Engagement to Product
2+
3+
from django.db import migrations
4+
5+
import logging
6+
7+
logger = logging.getLogger(__name__)
8+
9+
def migrate_risk_acceptance_to_product(apps, schema_editor):
10+
"""
11+
Migrate existing risk acceptances from engagement level to product level.
12+
For each risk acceptance, find its engagement and set the product field.
13+
"""
14+
Risk_Acceptance = apps.get_model('dojo', 'Risk_Acceptance')
15+
Engagement = apps.get_model('dojo', 'Engagement')
16+
17+
# Get all risk acceptances that don't have a product set
18+
risk_acceptances_updated = 0
19+
risk_acceptances_orphaned = 0
20+
21+
for risk_acceptance in Risk_Acceptance.objects.filter(product__isnull=True):
22+
# Find the engagement that has this risk acceptance
23+
engagement = Engagement.objects.filter(risk_acceptance=risk_acceptance).first()
24+
if engagement:
25+
# Set the product from the engagement
26+
risk_acceptance.product = engagement.product
27+
risk_acceptance.save()
28+
risk_acceptances_updated += 1
29+
else:
30+
# This shouldn't happen in normal cases, but if a risk acceptance has no engagement,
31+
# we need to handle it. We should delete.
32+
risk_acceptance.delete()
33+
risk_acceptances_orphaned += 1
34+
logger.warning(f"Risk Acceptance {risk_acceptance.id} '{risk_acceptance.name}' has no associated engagement so it can be removed.")
35+
36+
logger.debug(f"Migration complete: {risk_acceptances_updated} risk acceptances migrated to product level")
37+
if risk_acceptances_orphaned > 0:
38+
logger.warning(f"{risk_acceptances_orphaned} orphaned risk acceptances found (no associated engagement)")
39+
40+
41+
def reverse_migrate_risk_acceptance_to_engagement(apps, schema_editor):
42+
"""
43+
Reverse migration: restore engagement associations based on the product field.
44+
For each risk acceptance with a product, find an engagement in that product
45+
and associate the risk acceptance with it.
46+
"""
47+
Risk_Acceptance = apps.get_model('dojo', 'Risk_Acceptance')
48+
Engagement = apps.get_model('dojo', 'Engagement')
49+
50+
risk_acceptances_restored = 0
51+
52+
# For each risk acceptance with a product, find an engagement in that product
53+
# and associate the risk acceptance with it
54+
for risk_acceptance in Risk_Acceptance.objects.filter(product__isnull=False):
55+
# Find the first engagement in this product
56+
engagement = Engagement.objects.filter(product=risk_acceptance.product).first()
57+
if engagement:
58+
# Add the risk acceptance to the engagement
59+
engagement.risk_acceptance.add(risk_acceptance)
60+
risk_acceptances_restored += 1
61+
else:
62+
logger.warning(f"Could not find engagement for Risk Acceptance {risk_acceptance.id} in product {risk_acceptance.product.name}")
63+
64+
logger.debug(f"Reverse migration complete: {risk_acceptances_restored} risk acceptances restored to engagement level")
65+
66+
67+
class Migration(migrations.Migration):
68+
69+
dependencies = [
70+
('dojo', '0249_risk_acceptance_add_product_field'),
71+
]
72+
73+
operations = [
74+
# Populate the product field from engagement relationships
75+
migrations.RunPython(
76+
migrate_risk_acceptance_to_product,
77+
reverse_migrate_risk_acceptance_to_engagement
78+
),
79+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated migration - Step 3: Finalize Risk_Acceptance move to Product
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import pgtrigger
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('dojo', '0250_risk_acceptance_migrate_to_product'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='risk_acceptance',
17+
name='product',
18+
field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='risk_acceptances', to='dojo.product'),
19+
),
20+
migrations.RemoveField(
21+
model_name='engagement',
22+
name='risk_acceptance',
23+
),
24+
]

dojo/engagement/urls.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,6 @@
3434
name="engagement_unlink_jira"),
3535
re_path(r"^engagement/(?P<eid>\d+)/complete_checklist$",
3636
views.complete_checklist, name="complete_checklist"),
37-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/add$",
38-
views.add_risk_acceptance, name="add_risk_acceptance"),
39-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/add/(?P<fid>\d+)$",
40-
views.add_risk_acceptance, name="add_risk_acceptance"),
41-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/(?P<raid>\d+)$",
42-
views.view_risk_acceptance, name="view_risk_acceptance"),
43-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/(?P<raid>\d+)/edit$",
44-
views.edit_risk_acceptance, name="edit_risk_acceptance"),
45-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/(?P<raid>\d+)/expire$",
46-
views.expire_risk_acceptance, name="expire_risk_acceptance"),
47-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/(?P<raid>\d+)/reinstate$",
48-
views.reinstate_risk_acceptance, name="reinstate_risk_acceptance"),
49-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/(?P<raid>\d+)/delete$",
50-
views.delete_risk_acceptance, name="delete_risk_acceptance"),
51-
re_path(r"^engagement/(?P<eid>\d+)/risk_acceptance/(?P<raid>\d+)/download$",
52-
views.download_risk_acceptance, name="download_risk_acceptance"),
5337
re_path(r"^engagement/(?P<eid>\d+)/threatmodel$", views.view_threatmodel,
5438
name="view_threatmodel"),
5539
re_path(r"^engagement/(?P<eid>\d+)/threatmodel/upload$",

0 commit comments

Comments
 (0)