Skip to content

Commit b8a5c0f

Browse files
Maffoochclaude
andcommitted
Add permission check on destination product when moving engagements
When a user changes an engagement's product (via API PUT/PATCH or the UI edit form), verify they have Engagement_Edit permission on the destination product. Previously only the source product was checked, allowing users to move engagements to products they lack write access to. - API: EngagementSerializer.validate() checks destination product permission on update, following the ProductMemberSerializer pattern - UI: edit_engagement() view checks destination product permission before saving - Tests: 8 new tests covering PATCH, PUT, and UI paths for both authorized and unauthorized product moves Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 72861d0 commit b8a5c0f

3 files changed

Lines changed: 170 additions & 0 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,18 @@ def validate(self, data):
11231123
if data.get("target_start") > data.get("target_end"):
11241124
msg = "Your target start date exceeds your target end date"
11251125
raise serializers.ValidationError(msg)
1126+
if (
1127+
self.instance is not None
1128+
and "product" in data
1129+
and data.get("product") != self.instance.product
1130+
and not user_has_permission(
1131+
self.context["request"].user,
1132+
data.get("product"),
1133+
Permissions.Engagement_Edit,
1134+
)
1135+
):
1136+
msg = "You are not permitted to edit engagements in the destination product"
1137+
raise PermissionDenied(msg)
11261138
return data
11271139

11281140
def build_relational_field(self, field_name, relation_info):

dojo/engagement/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,12 @@ def edit_engagement(request, eid):
286286
if form.is_valid():
287287
# first save engagement details
288288
new_status = form.cleaned_data.get("status")
289+
if form.cleaned_data.get("product") != engagement.product:
290+
user_has_permission_or_403(
291+
request.user,
292+
form.cleaned_data.get("product"),
293+
Permissions.Engagement_Edit,
294+
)
289295
engagement.product = form.cleaned_data.get("product")
290296
engagement = form.save(commit=False)
291297
if (new_status in {"Cancelled", "Completed"}):

unittests/test_permissions_audit.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,3 +1425,155 @@ def test_risk_acceptance_download_proof_writer_allowed(self):
14251425

14261426
# Clean up uploaded file
14271427
self.risk_acceptance.path.delete(save=True)
1428+
1429+
1430+
class TestEngagementMovePermission(DojoTestCase):
1431+
1432+
"""Moving an engagement to another product requires Engagement_Edit on the destination."""
1433+
1434+
@classmethod
1435+
def setUpTestData(cls):
1436+
cls.owner_role = Role.objects.get(name="Owner")
1437+
cls.product_type = Product_Type.objects.create(name="Eng Move Test PT")
1438+
1439+
cls.product_a = Product.objects.create(
1440+
name="Eng Move Product A", description="Test", prod_type=cls.product_type,
1441+
)
1442+
cls.product_b = Product.objects.create(
1443+
name="Eng Move Product B", description="Test", prod_type=cls.product_type,
1444+
)
1445+
cls.product_c = Product.objects.create(
1446+
name="Eng Move Product C", description="Test", prod_type=cls.product_type,
1447+
)
1448+
1449+
cls.user = Dojo_User.objects.create_user(
1450+
username="eng_move_owner",
1451+
password="testTEST1234!@#$", # noqa: S106
1452+
is_active=True,
1453+
)
1454+
Product_Member.objects.create(product=cls.product_a, user=cls.user, role=cls.owner_role)
1455+
# No membership on product_b -- user cannot move engagements there
1456+
Product_Member.objects.create(product=cls.product_c, user=cls.user, role=cls.owner_role)
1457+
1458+
def setUp(self):
1459+
self.engagement = Engagement.objects.create(
1460+
name="Move Test Engagement",
1461+
product=self.product_a,
1462+
target_start=datetime.date.today(),
1463+
target_end=datetime.date.today(),
1464+
)
1465+
1466+
def _api_client(self):
1467+
token, _ = Token.objects.get_or_create(user=self.user)
1468+
client = APIClient()
1469+
client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
1470+
return client
1471+
1472+
def _ui_client(self):
1473+
client = Client()
1474+
client.login(username="eng_move_owner", password="testTEST1234!@#$") # noqa: S106
1475+
return client
1476+
1477+
# ── API: PATCH ────────────────────────────────────────────────────
1478+
1479+
def test_api_patch_move_to_authorized_product(self):
1480+
"""PATCH with product the user has access to should succeed."""
1481+
client = self._api_client()
1482+
url = reverse("engagement-detail", args=(self.engagement.id,))
1483+
response = client.patch(url, {"product": self.product_c.id}, format="json")
1484+
self.assertEqual(response.status_code, 200, response.content)
1485+
self.engagement.refresh_from_db()
1486+
self.assertEqual(self.engagement.product, self.product_c)
1487+
1488+
def test_api_patch_move_to_unauthorized_product(self):
1489+
"""PATCH with product the user lacks access to should be denied."""
1490+
client = self._api_client()
1491+
url = reverse("engagement-detail", args=(self.engagement.id,))
1492+
response = client.patch(url, {"product": self.product_b.id}, format="json")
1493+
self.assertEqual(response.status_code, 403, response.content)
1494+
self.engagement.refresh_from_db()
1495+
self.assertEqual(self.engagement.product, self.product_a)
1496+
1497+
def test_api_patch_same_product(self):
1498+
"""PATCH with the same product should succeed without extra permission check."""
1499+
client = self._api_client()
1500+
url = reverse("engagement-detail", args=(self.engagement.id,))
1501+
response = client.patch(url, {"product": self.product_a.id}, format="json")
1502+
self.assertEqual(response.status_code, 200, response.content)
1503+
1504+
def test_api_patch_without_product_field(self):
1505+
"""PATCH without product field should succeed (no spurious check)."""
1506+
client = self._api_client()
1507+
url = reverse("engagement-detail", args=(self.engagement.id,))
1508+
response = client.patch(url, {"version": "1.0"}, format="json")
1509+
self.assertEqual(response.status_code, 200, response.content)
1510+
1511+
# ── API: PUT ──────────────────────────────────────────────────────
1512+
1513+
def test_api_put_move_to_authorized_product(self):
1514+
"""PUT with product the user has access to should succeed."""
1515+
client = self._api_client()
1516+
url = reverse("engagement-detail", args=(self.engagement.id,))
1517+
payload = {
1518+
"name": "Move Test Engagement",
1519+
"product": self.product_c.id,
1520+
"target_start": str(datetime.date.today()),
1521+
"target_end": str(datetime.date.today()),
1522+
"engagement_type": "Interactive",
1523+
"status": "Not Started",
1524+
}
1525+
response = client.put(url, payload, format="json")
1526+
self.assertEqual(response.status_code, 200, response.content)
1527+
self.engagement.refresh_from_db()
1528+
self.assertEqual(self.engagement.product, self.product_c)
1529+
1530+
def test_api_put_move_to_unauthorized_product(self):
1531+
"""PUT with product the user lacks access to should be denied."""
1532+
client = self._api_client()
1533+
url = reverse("engagement-detail", args=(self.engagement.id,))
1534+
payload = {
1535+
"name": "Move Test Engagement",
1536+
"product": self.product_b.id,
1537+
"target_start": str(datetime.date.today()),
1538+
"target_end": str(datetime.date.today()),
1539+
"engagement_type": "Interactive",
1540+
"status": "Not Started",
1541+
}
1542+
response = client.put(url, payload, format="json")
1543+
self.assertEqual(response.status_code, 403, response.content)
1544+
self.engagement.refresh_from_db()
1545+
self.assertEqual(self.engagement.product, self.product_a)
1546+
1547+
# ── UI ────────────────────────────────────────────────────────────
1548+
1549+
def test_ui_move_to_authorized_product(self):
1550+
"""Edit engagement form moving to authorized product should succeed."""
1551+
client = self._ui_client()
1552+
url = reverse("edit_engagement", args=(self.engagement.id,))
1553+
form_data = {
1554+
"product": self.product_c.id,
1555+
"target_start": datetime.date.today().strftime("%Y-%m-%d"),
1556+
"target_end": datetime.date.today().strftime("%Y-%m-%d"),
1557+
"lead": self.user.id,
1558+
"status": "Not Started",
1559+
}
1560+
response = client.post(url, form_data)
1561+
self.assertIn(response.status_code, [200, 302], response.content[:500])
1562+
self.engagement.refresh_from_db()
1563+
self.assertEqual(self.engagement.product, self.product_c)
1564+
1565+
def test_ui_move_to_unauthorized_product(self):
1566+
"""Edit engagement form moving to unauthorized product should be denied."""
1567+
client = self._ui_client()
1568+
url = reverse("edit_engagement", args=(self.engagement.id,))
1569+
form_data = {
1570+
"product": self.product_b.id,
1571+
"target_start": datetime.date.today().strftime("%Y-%m-%d"),
1572+
"target_end": datetime.date.today().strftime("%Y-%m-%d"),
1573+
"lead": self.user.id,
1574+
"status": "Not Started",
1575+
}
1576+
response = client.post(url, form_data)
1577+
self.assertEqual(response.status_code, 403)
1578+
self.engagement.refresh_from_db()
1579+
self.assertEqual(self.engagement.product, self.product_a)

0 commit comments

Comments
 (0)