Skip to content

Commit 98d0cc3

Browse files
authored
product updates and tests (#15170)
1 parent 56d455a commit 98d0cc3

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

dojo/product/api/serializer.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from rest_framework import serializers
2+
from rest_framework.exceptions import PermissionDenied
23

34
from dojo.models import DojoMeta, Product, Product_API_Scan_Configuration
45

@@ -50,8 +51,48 @@ def validate(self, data):
5051
if new_sla_config and old_sla_config and new_sla_config != old_sla_config:
5152
msg = "Finding SLA expiration dates are currently being recalculated. The SLA configuration for this product cannot be changed until the calculation is complete."
5253
raise serializers.ValidationError(msg)
54+
self._validate_authorized_users_change(data)
5355
return data
5456

57+
def _validate_authorized_users_change(self, data):
58+
"""
59+
Writing ``authorized_users`` is a member-management operation and is
60+
gated behind ``Product_Manage_Members`` -- the same permission the web
61+
UI requires (dojo.product.ui.views.add_product_authorized_users). The
62+
rest of this endpoint is governed by ``Product_Edit``, so this keeps
63+
changes to the membership list aligned with the dedicated
64+
member-management permission.
65+
66+
No-ops when the field is absent or unchanged (replay-safe), mirroring
67+
dojo.authorization.api_permissions.check_update_permission.
68+
"""
69+
if "authorized_users" not in data:
70+
return
71+
72+
# Field-level validation has already resolved the payload to Dojo_User
73+
# instances at this point.
74+
from dojo.authorization.authorization import ( # noqa: PLC0415 -- lazy import, avoids circular dependency
75+
user_has_permission,
76+
)
77+
from dojo.authorization.roles_permissions import ( # noqa: PLC0415 -- lazy import, avoids circular dependency
78+
Permissions,
79+
)
80+
81+
new_ids = sorted(user.pk for user in (data.get("authorized_users") or []))
82+
current_ids = (
83+
sorted(self.instance.authorized_users.values_list("pk", flat=True))
84+
if self.instance is not None
85+
else []
86+
)
87+
if new_ids == current_ids:
88+
return
89+
90+
request = self.context.get("request")
91+
request_user = getattr(request, "user", None)
92+
if not (request_user and user_has_permission(request_user, self.instance, Permissions.Product_Manage_Members)):
93+
msg = "You do not have permission to manage authorized users for this product."
94+
raise PermissionDenied(msg)
95+
5596
def get_findings_count(self, obj) -> int:
5697
return obj.findings_count
5798

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from django.urls import reverse
2+
from rest_framework.authtoken.models import Token
3+
from rest_framework.test import APIClient
4+
5+
from dojo.models import Dojo_User, Product, Product_Type, User
6+
from unittests.dojo_test_case import DojoAPITestCase, versioned_fixtures
7+
8+
9+
@versioned_fixtures
10+
class ProductAuthorizedUsersApiPermissionTest(DojoAPITestCase):
11+
12+
"""
13+
Permission coverage for the ``authorized_users`` field on the Product API.
14+
15+
``authorized_users`` is exposed as a writable field on ProductSerializer.
16+
Changing it is a member-management operation, gated behind
17+
``Product_Manage_Members`` -- the same permission the web UI requires in
18+
dojo.product.ui.views.add_product_authorized_users. The rest of the
19+
endpoint is governed by ``Product_Edit``. These tests pin that changes to
20+
the membership list go through the member-management permission, while
21+
ordinary product edits and no-op submissions are unaffected.
22+
"""
23+
24+
fixtures = ["dojo_testdata.json"]
25+
26+
@classmethod
27+
def setUpTestData(cls):
28+
prod_type = Product_Type.objects.create(name="PAU-Perm PT")
29+
cls.product = Product.objects.create(
30+
name="PAU-Perm Product",
31+
description="product for authorized_users permission tests",
32+
prod_type=prod_type,
33+
)
34+
35+
# Alice: holds Product_Edit on the product (via authorized_users membership).
36+
cls.alice = User.objects.create_user(
37+
username="pau_alice",
38+
password="not-a-real-secret", # noqa: S106 - test fixture user
39+
is_staff=False,
40+
)
41+
cls.product.authorized_users.add(Dojo_User.objects.get(pk=cls.alice.pk))
42+
43+
# Bob: another user, not initially a member of the product.
44+
cls.bob = User.objects.create_user(
45+
username="pau_bob",
46+
password="not-a-real-secret", # noqa: S106 - test fixture user
47+
is_staff=False,
48+
)
49+
50+
# Staff user, who holds Product_Manage_Members.
51+
cls.admin = User.objects.create_user(
52+
username="pau_admin",
53+
password="not-a-real-secret", # noqa: S106 - test fixture user
54+
is_staff=True,
55+
)
56+
57+
cls.detail_url = reverse("product-detail", args=[cls.product.id])
58+
59+
def _client_for(self, user):
60+
token, _ = Token.objects.get_or_create(user=user)
61+
client = APIClient()
62+
client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
63+
return client
64+
65+
def test_product_edit_cannot_add_authorized_users(self):
66+
client = self._client_for(self.alice)
67+
68+
# Product_Edit alone permits ordinary field updates.
69+
response = client.patch(
70+
self.detail_url, {"description": "edited via product edit"}, format="json",
71+
)
72+
self.assertEqual(200, response.status_code, response.content[:500])
73+
74+
# Adding a user to authorized_users requires Product_Manage_Members.
75+
self.assertFalse(self.product.authorized_users.filter(pk=self.bob.pk).exists())
76+
response = client.patch(
77+
self.detail_url,
78+
{"authorized_users": [self.alice.pk, self.bob.pk]},
79+
format="json",
80+
)
81+
self.assertEqual(403, response.status_code, response.content[:500])
82+
83+
self.product.refresh_from_db()
84+
self.assertFalse(self.product.authorized_users.filter(pk=self.bob.pk).exists())
85+
self.assertTrue(self.product.authorized_users.filter(pk=self.alice.pk).exists())
86+
87+
def test_product_edit_cannot_replace_authorized_users(self):
88+
# PATCH replaces the M2M via .set(); replacing the list is also a
89+
# member-management change and requires Product_Manage_Members.
90+
client = self._client_for(self.alice)
91+
response = client.patch(
92+
self.detail_url, {"authorized_users": [self.bob.pk]}, format="json",
93+
)
94+
self.assertEqual(403, response.status_code, response.content[:500])
95+
96+
self.product.refresh_from_db()
97+
self.assertTrue(self.product.authorized_users.filter(pk=self.alice.pk).exists())
98+
self.assertFalse(self.product.authorized_users.filter(pk=self.bob.pk).exists())
99+
100+
def test_product_edit_unchanged_authorized_users_is_allowed(self):
101+
# Replay-safe: re-submitting the current membership set unchanged is not
102+
# a member-management change and is accepted.
103+
client = self._client_for(self.alice)
104+
response = client.patch(
105+
self.detail_url, {"authorized_users": [self.alice.pk]}, format="json",
106+
)
107+
self.assertEqual(200, response.status_code, response.content[:500])
108+
109+
def test_non_member_cannot_change_authorized_users(self):
110+
# A user who is not a member of the product cannot retrieve it (the
111+
# viewset queryset is scoped to authorized products), so the PATCH is
112+
# rejected before the member-management check is reached.
113+
client = self._client_for(self.bob)
114+
115+
response = client.get(self.detail_url)
116+
self.assertIn(response.status_code, {403, 404}, response.content[:500])
117+
118+
response = client.patch(
119+
self.detail_url, {"authorized_users": [self.bob.pk]}, format="json",
120+
)
121+
self.assertIn(response.status_code, {403, 404}, response.content[:500])
122+
123+
self.product.refresh_from_db()
124+
self.assertFalse(self.product.authorized_users.filter(pk=self.bob.pk).exists())
125+
126+
def test_manage_members_permission_can_change_authorized_users(self):
127+
# Product_Manage_Members (staff) can update the membership list.
128+
client = self._client_for(self.admin)
129+
response = client.patch(
130+
self.detail_url,
131+
{"authorized_users": [self.alice.pk, self.bob.pk]},
132+
format="json",
133+
)
134+
self.assertEqual(200, response.status_code, response.content[:500])
135+
136+
self.product.refresh_from_db()
137+
self.assertTrue(self.product.authorized_users.filter(pk=self.bob.pk).exists())

0 commit comments

Comments
 (0)