Skip to content

Commit c8c8297

Browse files
authored
Notes: API perms for read only users + note history tracking (#14284)
* Add engagement, finding, and test note permissions * Update note permissions in Engagement, Finding, and Test viewsets * Add note history tracking to Engagement, Finding, and Test viewsets * add tests * Correct test memory issue * Apply suggestions from code review
1 parent cc4d58a commit c8c8297

3 files changed

Lines changed: 104 additions & 43 deletions

File tree

dojo/api_v2/permissions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,15 @@ class UserHasEngagementRelatedObjectPermission(BaseRelatedObjectPermission):
385385
}
386386

387387

388+
class UserHasEngagementNotePermission(BaseRelatedObjectPermission):
389+
permission_map = {
390+
"get_permission": Permissions.Engagement_View,
391+
"put_permission": Permissions.Engagement_Edit,
392+
"delete_permission": Permissions.Engagement_Edit,
393+
"post_permission": Permissions.Engagement_View,
394+
}
395+
396+
388397
class UserHasRiskAcceptancePermission(permissions.BasePermission):
389398
def has_permission(self, request, view):
390399
# The previous implementation only checked for the object permission if the path was
@@ -437,6 +446,15 @@ class UserHasFindingRelatedObjectPermission(BaseRelatedObjectPermission):
437446
}
438447

439448

449+
class UserHasFindingNotePermission(BaseRelatedObjectPermission):
450+
permission_map = {
451+
"get_permission": Permissions.Finding_View,
452+
"put_permission": Permissions.Finding_Edit,
453+
"delete_permission": Permissions.Finding_Edit,
454+
"post_permission": Permissions.Finding_View,
455+
}
456+
457+
440458
class UserHasImportPermission(permissions.BasePermission):
441459
def has_permission(self, request, view):
442460
# permission check takes place before validation, so we don't have access to serializer.validated_data()
@@ -817,6 +835,15 @@ class UserHasTestRelatedObjectPermission(BaseRelatedObjectPermission):
817835
}
818836

819837

838+
class UserHasTestNotePermission(BaseRelatedObjectPermission):
839+
permission_map = {
840+
"get_permission": Permissions.Test_View,
841+
"put_permission": Permissions.Test_Edit,
842+
"delete_permission": Permissions.Test_Edit,
843+
"post_permission": Permissions.Test_View,
844+
}
845+
846+
820847
class UserHasTestImportPermission(permissions.BasePermission):
821848
def has_permission(self, request, view):
822849
return check_post_permission(

dojo/api_v2/views.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
Languages,
122122
Network_Locations,
123123
Note_Type,
124+
NoteHistory,
124125
Notes,
125126
Notification_Webhooks,
126127
Notifications,
@@ -504,7 +505,7 @@ def generate_report(self, request, pk=None):
504505
request=serializers.AddNewNoteOptionSerializer,
505506
responses={status.HTTP_201_CREATED: serializers.NoteSerializer},
506507
)
507-
@action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission])
508+
@action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, permissions.UserHasEngagementNotePermission])
508509
def notes(self, request, pk=None):
509510
engagement = self.get_object()
510511
if request.method == "POST":
@@ -532,6 +533,10 @@ def notes(self, request, pk=None):
532533
note_type=note_type,
533534
)
534535
note.save()
536+
# Add an entry to the note history
537+
history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author)
538+
note.history.add(history)
539+
# Now add the note to the object
535540
engagement.notes.add(note)
536541
# Determine if we need to send any notifications for user mentioned
537542
process_tag_notifications(
@@ -1096,7 +1101,7 @@ def request_response(self, request, pk=None):
10961101
request=serializers.AddNewNoteOptionSerializer,
10971102
responses={status.HTTP_201_CREATED: serializers.NoteSerializer},
10981103
)
1099-
@action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission))
1104+
@action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission))
11001105
def notes(self, request, pk=None):
11011106
finding = self.get_object()
11021107
if request.method == "POST":
@@ -1125,6 +1130,10 @@ def notes(self, request, pk=None):
11251130
note_type=note_type,
11261131
)
11271132
note.save()
1133+
# Add an entry to the note history
1134+
history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author)
1135+
note.history.add(history)
1136+
# Now add the note to the object
11281137
finding.last_reviewed = note.date
11291138
finding.last_reviewed_by = author
11301139
finding.save(update_fields=["last_reviewed", "last_reviewed_by", "updated"])
@@ -1226,7 +1235,7 @@ def download_file(self, request, file_id, pk=None):
12261235
request=serializers.FindingNoteSerializer,
12271236
responses={status.HTTP_204_NO_CONTENT: ""},
12281237
)
1229-
@action(detail=True, methods=["patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission))
1238+
@action(detail=True, methods=["patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission))
12301239
def remove_note(self, request, pk=None):
12311240
"""Remove Note From Finding Note"""
12321241
finding = self.get_object()
@@ -2162,7 +2171,7 @@ def generate_report(self, request, pk=None):
21622171
request=serializers.AddNewNoteOptionSerializer,
21632172
responses={status.HTTP_201_CREATED: serializers.NoteSerializer},
21642173
)
2165-
@action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasTestRelatedObjectPermission))
2174+
@action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasTestNotePermission))
21662175
def notes(self, request, pk=None):
21672176
test = self.get_object()
21682177
if request.method == "POST":
@@ -2190,6 +2199,10 @@ def notes(self, request, pk=None):
21902199
note_type=note_type,
21912200
)
21922201
note.save()
2202+
# Add an entry to the note history
2203+
history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author)
2204+
note.history.add(history)
2205+
# Now add the note to the object
21932206
test.notes.add(note)
21942207
# Determine if we need to send any notifications for user mentioned
21952208
process_tag_notifications(

unittests/test_rest_framework.py

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,63 @@ def test_detail_configuration_not_authorized(self):
856856
response = self.client.get(relative_url)
857857
self.assertEqual(200, response.status_code, response.content[:1000])
858858

859+
class RelatedObjectsTest(BaseClassTest):
860+
def test_notes_can_be_added_by_users_with_read_only_permissions(self):
861+
self.setUp_global_reader()
862+
response = self.client.get(self.url, format="json")
863+
self.assertEqual(200, response.status_code, response.content[:1000])
864+
engagement_id = response.data["results"][0]["id"]
865+
# Attempt to add a note with reader permissions
866+
relative_url = f"{self.url}{engagement_id}/notes/"
867+
response = self.client.post(relative_url, {"entry": "string"})
868+
self.assertEqual(201, response.status_code, response.content[:1000])
869+
870+
@parameterized.expand(
871+
[
872+
("files", {"title": "test"}),
873+
("tags", {"tags": ["apple", "banana", "cherry"]}),
874+
],
875+
)
876+
def test_related_objects(self, related_object_path, payload):
877+
"""
878+
Tests that BaseRelatedObjectPermission enforces the permissions not associated
879+
with the base object. For example, even though a request to add a tag to an
880+
engagement is a POST, we do not need engagement add permissions, but rather
881+
engagement edit permissions since that is what is defined in the
882+
UserHasEngagementRelatedObjectPermission class
883+
"""
884+
self.setUp_global_reader()
885+
# Skip tags for engagement and tests
886+
if related_object_path == "tags" and self.endpoint_model in {Engagement, Test}:
887+
return
888+
# Get an object
889+
response = self.client.get(self.url, format="json")
890+
self.assertEqual(200, response.status_code, response.content[:1000])
891+
object_id = response.data["results"][0]["id"]
892+
# Attempt to add a related object
893+
relative_url = f"{self.url}{object_id}/{related_object_path}/"
894+
response = self.client.post(relative_url, payload)
895+
self.assertEqual(403, response.status_code, response.content[:1000])
896+
# Now switch to a user with edit permissions (but not create)
897+
self.setUp_global_writer()
898+
# Retry adding the related object
899+
if related_object_path == "files":
900+
# Convert bytes to a mock uploaded file
901+
response = self.client.post(
902+
relative_url,
903+
{
904+
"file": SimpleUploadedFile(
905+
name="test_file.txt",
906+
content=b"empty",
907+
content_type="text/plain",
908+
),
909+
**payload,
910+
},
911+
)
912+
else:
913+
response = self.client.post(relative_url, payload)
914+
self.assertIn(response.status_code, [200, 201], response.content[:1000])
915+
859916

860917
@versioned_fixtures
861918
class AppAnalysisTest(BaseClass.BaseClassTest):
@@ -1487,7 +1544,7 @@ def test_update_object_not_authorized(self):
14871544

14881545

14891546
@versioned_fixtures
1490-
class EngagementTest(BaseClass.BaseClassTest):
1547+
class EngagementTest(BaseClass.RelatedObjectsTest, BaseClass.BaseClassTest):
14911548
fixtures = ["dojo_testdata.json"]
14921549

14931550
def __init__(self, *args, **kwargs):
@@ -1517,42 +1574,6 @@ def __init__(self, *args, **kwargs):
15171574
self.deleted_objects = 23
15181575
BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs)
15191576

1520-
@parameterized.expand(
1521-
[
1522-
("files", {"title": "test", "file": b"empty"}),
1523-
("notes", {"entry": "string"}),
1524-
],
1525-
)
1526-
def test_related_objects(self, related_object_path, payload):
1527-
"""
1528-
Tests that BaseRelatedObjectPermission enforces the permissions not associated
1529-
with the base object. For example, even though a request to add a note to an
1530-
engagement is a POST, we do not need engagement add permissions, but rather
1531-
engagement edit permissions since that is what is defined in the
1532-
UserHasEngagementRelatedObjectPermission class
1533-
"""
1534-
self.setUp_global_reader()
1535-
# Get an engagement
1536-
response = self.client.get(self.url, format="json")
1537-
self.assertEqual(200, response.status_code, response.content[:1000])
1538-
engagement_id = response.data["results"][0]["id"]
1539-
# Attempt to add a related object
1540-
relative_url = f"{self.url}{engagement_id}/{related_object_path}/"
1541-
response = self.client.post(relative_url, payload)
1542-
self.assertEqual(403, response.status_code, response.content[:1000])
1543-
# Now switch to a user with edit permissions (but not create)
1544-
self.setUp_global_writer()
1545-
# Retry adding the related object
1546-
if related_object_path == "files":
1547-
# Convert bytes to a mock uploaded file
1548-
payload["file"] = SimpleUploadedFile(
1549-
name="test_file.txt",
1550-
content=payload["file"], # the b"empty"
1551-
content_type="text/plain",
1552-
)
1553-
response = self.client.post(relative_url, payload)
1554-
self.assertEqual(201, response.status_code, response.content[:1000])
1555-
15561577

15571578
@versioned_fixtures
15581579
class RiskAcceptanceTest(BaseClass.BaseClassTest):
@@ -1726,7 +1747,7 @@ def test_file_with_quoted_name(self):
17261747

17271748

17281749
@versioned_fixtures
1729-
class FindingsTest(BaseClass.BaseClassTest):
1750+
class FindingsTest(BaseClass.RelatedObjectsTest, BaseClass.BaseClassTest):
17301751
fixtures = ["dojo_testdata.json"]
17311752

17321753
def __init__(self, *args, **kwargs):
@@ -2415,7 +2436,7 @@ def test_severity_validation(self):
24152436

24162437

24172438
@versioned_fixtures
2418-
class TestsTest(BaseClass.BaseClassTest):
2439+
class TestsTest(BaseClass.RelatedObjectsTest, BaseClass.BaseClassTest):
24192440
fixtures = ["dojo_testdata.json"]
24202441

24212442
def __init__(self, *args, **kwargs):

0 commit comments

Comments
 (0)