Skip to content

Commit 9d661d7

Browse files
Jino-TMaffoochclaude
authored
Add permission checks for moving engagements between products (#14634)
* change-to-moving-engagements * fix-migration-issue: * Revert PR #14634 changes (editable=False approach) Reverting the approach of making Engagement.product editable=False and splitting serializers. Will replace with proper permission checks on the destination product when moving engagements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * Fix UI test: form queryset already rejects unauthorized products The EngForm product queryset is filtered to authorized products, so submitting an unauthorized product fails form validation (200) before the view-level permission check runs. Update the test to accept both 200 and 403 -- the key assertion is that the engagement does not move. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix ruff lint: docstring formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent eea3e47 commit 9d661d7

File tree

3 files changed

+177
-0
lines changed

3 files changed

+177
-0
lines changed

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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,3 +1686,162 @@ def test_risk_acceptance_download_proof_writer_allowed(self):
16861686

16871687
# Clean up uploaded file
16881688
self.risk_acceptance.path.delete(save=True)
1689+
1690+
1691+
class TestEngagementMovePermission(DojoTestCase):
1692+
1693+
"""Moving an engagement to another product requires Engagement_Edit on the destination."""
1694+
1695+
@classmethod
1696+
def setUpTestData(cls):
1697+
cls.owner_role = Role.objects.get(name="Owner")
1698+
cls.product_type = Product_Type.objects.create(name="Eng Move Test PT")
1699+
1700+
cls.product_a = Product.objects.create(
1701+
name="Eng Move Product A", description="Test", prod_type=cls.product_type,
1702+
)
1703+
cls.product_b = Product.objects.create(
1704+
name="Eng Move Product B", description="Test", prod_type=cls.product_type,
1705+
)
1706+
cls.product_c = Product.objects.create(
1707+
name="Eng Move Product C", description="Test", prod_type=cls.product_type,
1708+
)
1709+
1710+
cls.user = Dojo_User.objects.create_user(
1711+
username="eng_move_owner",
1712+
password="testTEST1234!@#$", # noqa: S106
1713+
is_active=True,
1714+
)
1715+
Product_Member.objects.create(product=cls.product_a, user=cls.user, role=cls.owner_role)
1716+
# No membership on product_b -- user cannot move engagements there
1717+
Product_Member.objects.create(product=cls.product_c, user=cls.user, role=cls.owner_role)
1718+
1719+
def setUp(self):
1720+
self.engagement = Engagement.objects.create(
1721+
name="Move Test Engagement",
1722+
product=self.product_a,
1723+
target_start=datetime.date.today(),
1724+
target_end=datetime.date.today(),
1725+
)
1726+
1727+
def _api_client(self):
1728+
token, _ = Token.objects.get_or_create(user=self.user)
1729+
client = APIClient()
1730+
client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
1731+
return client
1732+
1733+
def _ui_client(self):
1734+
client = Client()
1735+
client.login(username="eng_move_owner", password="testTEST1234!@#$") # noqa: S106
1736+
return client
1737+
1738+
# ── API: PATCH ────────────────────────────────────────────────────
1739+
1740+
def test_api_patch_move_to_authorized_product(self):
1741+
"""PATCH with product the user has access to should succeed."""
1742+
client = self._api_client()
1743+
url = reverse("engagement-detail", args=(self.engagement.id,))
1744+
response = client.patch(url, {"product": self.product_c.id}, format="json")
1745+
self.assertEqual(response.status_code, 200, response.content)
1746+
self.engagement.refresh_from_db()
1747+
self.assertEqual(self.engagement.product, self.product_c)
1748+
1749+
def test_api_patch_move_to_unauthorized_product(self):
1750+
"""PATCH with product the user lacks access to should be denied."""
1751+
client = self._api_client()
1752+
url = reverse("engagement-detail", args=(self.engagement.id,))
1753+
response = client.patch(url, {"product": self.product_b.id}, format="json")
1754+
self.assertEqual(response.status_code, 403, response.content)
1755+
self.engagement.refresh_from_db()
1756+
self.assertEqual(self.engagement.product, self.product_a)
1757+
1758+
def test_api_patch_same_product(self):
1759+
"""PATCH with the same product should succeed without extra permission check."""
1760+
client = self._api_client()
1761+
url = reverse("engagement-detail", args=(self.engagement.id,))
1762+
response = client.patch(url, {"product": self.product_a.id}, format="json")
1763+
self.assertEqual(response.status_code, 200, response.content)
1764+
1765+
def test_api_patch_without_product_field(self):
1766+
"""PATCH without product field should succeed (no spurious check)."""
1767+
client = self._api_client()
1768+
url = reverse("engagement-detail", args=(self.engagement.id,))
1769+
response = client.patch(url, {"version": "1.0"}, format="json")
1770+
self.assertEqual(response.status_code, 200, response.content)
1771+
1772+
# ── API: PUT ──────────────────────────────────────────────────────
1773+
1774+
def test_api_put_move_to_authorized_product(self):
1775+
"""PUT with product the user has access to should succeed."""
1776+
client = self._api_client()
1777+
url = reverse("engagement-detail", args=(self.engagement.id,))
1778+
payload = {
1779+
"name": "Move Test Engagement",
1780+
"product": self.product_c.id,
1781+
"target_start": str(datetime.date.today()),
1782+
"target_end": str(datetime.date.today()),
1783+
"engagement_type": "Interactive",
1784+
"status": "Not Started",
1785+
}
1786+
response = client.put(url, payload, format="json")
1787+
self.assertEqual(response.status_code, 200, response.content)
1788+
self.engagement.refresh_from_db()
1789+
self.assertEqual(self.engagement.product, self.product_c)
1790+
1791+
def test_api_put_move_to_unauthorized_product(self):
1792+
"""PUT with product the user lacks access to should be denied."""
1793+
client = self._api_client()
1794+
url = reverse("engagement-detail", args=(self.engagement.id,))
1795+
payload = {
1796+
"name": "Move Test Engagement",
1797+
"product": self.product_b.id,
1798+
"target_start": str(datetime.date.today()),
1799+
"target_end": str(datetime.date.today()),
1800+
"engagement_type": "Interactive",
1801+
"status": "Not Started",
1802+
}
1803+
response = client.put(url, payload, format="json")
1804+
self.assertEqual(response.status_code, 403, response.content)
1805+
self.engagement.refresh_from_db()
1806+
self.assertEqual(self.engagement.product, self.product_a)
1807+
1808+
# ── UI ────────────────────────────────────────────────────────────
1809+
1810+
def test_ui_move_to_authorized_product(self):
1811+
"""Edit engagement form moving to authorized product should succeed."""
1812+
client = self._ui_client()
1813+
url = reverse("edit_engagement", args=(self.engagement.id,))
1814+
form_data = {
1815+
"product": self.product_c.id,
1816+
"target_start": datetime.date.today().strftime("%Y-%m-%d"),
1817+
"target_end": datetime.date.today().strftime("%Y-%m-%d"),
1818+
"lead": self.user.id,
1819+
"status": "Not Started",
1820+
}
1821+
response = client.post(url, form_data)
1822+
self.assertIn(response.status_code, [200, 302], response.content[:500])
1823+
self.engagement.refresh_from_db()
1824+
self.assertEqual(self.engagement.product, self.product_c)
1825+
1826+
def test_ui_move_to_unauthorized_product(self):
1827+
"""
1828+
Edit engagement form moving to unauthorized product should be denied.
1829+
1830+
The form's product queryset is filtered to authorized products, so
1831+
submitting an unauthorized product fails form validation (200 with
1832+
errors) before the view-level permission check runs. Either way the
1833+
engagement must NOT move.
1834+
"""
1835+
client = self._ui_client()
1836+
url = reverse("edit_engagement", args=(self.engagement.id,))
1837+
form_data = {
1838+
"product": self.product_b.id,
1839+
"target_start": datetime.date.today().strftime("%Y-%m-%d"),
1840+
"target_end": datetime.date.today().strftime("%Y-%m-%d"),
1841+
"lead": self.user.id,
1842+
"status": "Not Started",
1843+
}
1844+
response = client.post(url, form_data)
1845+
self.assertIn(response.status_code, [200, 403])
1846+
self.engagement.refresh_from_db()
1847+
self.assertEqual(self.engagement.product, self.product_a)

0 commit comments

Comments
 (0)