Compare commits

...
Author SHA1 Message Date
Trenton HolmesandClaude Sonnet 5 a167be5e1f Fix: use validate_set_permissions' normalized dict, dedupe action list
BulkEditObjectsSerializer._validate_permissions discarded the
id-checked/None-normalized dict validate_set_permissions returns, so a
literal null for users/groups reached set_permissions_for_objects raw
and blew up with an uncaught TypeError, masked by the view's broad
except into a vague "check logs" 400. Now stores the returned dict back
onto attrs["permissions"].

Also replaced the two independently hardcoded ("view", "change") lists
in SetPermissionsSerializer.to_internal_value and
validate_set_permissions with one PERMISSION_ACTIONS constant, so
adding a new action can't update one and silently miss the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:26:40 -07:00
Trenton HolmesandClaude Sonnet 5 4dc1736bee Fix: enforce set_permissions/permissions shape via a real DRF serializer
SetPermissionsSerializer was a bare DictField, and BulkEditSerializer's
set_permissions parameter bypassed even that by hand-calling
validate_set_permissions() on an unchecked dict pulled out of the
generic parameters bag. A bool or nested bool in place of a
users/groups list crashed with a raw TypeError instead of a 400.

Replaced it with a nested serializer (view/change, each with
users/groups as ListField(child=IntegerField())), routed the
BulkEditSerializer bypass through its validation, reused it for
BulkEditObjectsSerializer.permissions, and added a check for unknown
top-level action keys (previously silently dropped, defeating the
Permission.DoesNotExist safeguard in permissions.py for typo'd
actions). Preserved the two intentional no-op cases: an explicit
top-level None (a request that only changes owner) and rejecting an
explicit empty permissions dict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:10:21 -07:00
3 changed files with 240 additions and 48 deletions
+44 -47
View File
@@ -210,6 +210,9 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
return match return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin: class SetPermissionsMixin:
def _validate_user_ids(self, user_ids): def _validate_user_ids(self, user_ids):
users = User.objects.none() users = User.objects.none()
@@ -232,12 +235,9 @@ class SetPermissionsMixin:
return groups return groups
def validate_set_permissions(self, set_permissions=None): def validate_set_permissions(self, set_permissions=None):
permissions_dict = { permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
"view": {},
"change": {},
}
if set_permissions is not None: if set_permissions is not None:
for action in ["view", "change"]: for action in PERMISSION_ACTIONS:
if action in set_permissions: if action in set_permissions:
if "users" in set_permissions[action]: if "users" in set_permissions[action]:
users = set_permissions[action]["users"] users = set_permissions[action]["users"]
@@ -265,41 +265,31 @@ class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@extend_schema_field( class _PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
field={ users = serializers.ListField(
"type": "object", child=serializers.IntegerField(),
"properties": { required=False,
"view": { allow_null=True,
"type": "object", )
"properties": { groups = serializers.ListField(
"users": { child=serializers.IntegerField(),
"type": "array", required=False,
"items": {"type": "integer"}, allow_null=True,
}, )
"groups": {
"type": "array",
"items": {"type": "integer"}, class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
}, view = _PermissionSetSerializer(required=False)
}, change = _PermissionSetSerializer(required=False)
},
"change": { def to_internal_value(self, data):
"type": "object", if isinstance(data, dict):
"properties": { unknown_keys = set(data) - set(PERMISSION_ACTIONS)
"users": { if unknown_keys:
"type": "array", raise serializers.ValidationError(
"items": {"type": "integer"}, {key: "Unknown permission action." for key in sorted(unknown_keys)},
}, )
"groups": { return super().to_internal_value(data)
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
class SetPermissionsSerializer(serializers.DictField):
pass
class OwnedObjectSerializer( class OwnedObjectSerializer(
@@ -470,7 +460,6 @@ class OwnedObjectSerializer(
set_permissions = SetPermissionsSerializer( set_permissions = SetPermissionsSerializer(
label="Set permissions", label="Set permissions",
allow_empty=True,
required=False, required=False,
write_only=True, write_only=True,
) )
@@ -2045,8 +2034,13 @@ class BulkEditSerializer(
def _validate_parameters_set_permissions(self, parameters) -> None: def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters: if "set_permissions" not in parameters:
raise serializers.ValidationError("set_permissions not specified") raise serializers.ValidationError("set_permissions not specified")
set_permissions = parameters["set_permissions"]
if set_permissions is not None:
set_permissions = SetPermissionsSerializer().run_validation(
set_permissions,
)
parameters["set_permissions"] = self.validate_set_permissions( parameters["set_permissions"] = self.validate_set_permissions(
parameters["set_permissions"], set_permissions,
) )
if "owner" in parameters and parameters["owner"] is not None: if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"]) self._validate_owner(parameters["owner"])
@@ -3001,9 +2995,8 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
allow_null=True, allow_null=True,
) )
permissions = serializers.DictField( permissions = SetPermissionsSerializer(
label="Set permissions", label="Set permissions",
allow_empty=False,
required=False, required=False,
write_only=True, write_only=True,
) )
@@ -3039,8 +3032,8 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
) )
return objects return objects
def _validate_permissions(self, permissions) -> None: def _validate_permissions(self, permissions) -> dict:
self.validate_set_permissions( return self.validate_set_permissions(
permissions, permissions,
) )
@@ -3064,7 +3057,11 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
if operation == "set_permissions": if operation == "set_permissions":
permissions = attrs.get("permissions") permissions = attrs.get("permissions")
if permissions is not None: if permissions is not None:
self._validate_permissions(permissions) if not permissions:
raise serializers.ValidationError(
"permissions must not be empty",
)
attrs["permissions"] = self._validate_permissions(permissions)
return attrs return attrs
+93 -1
View File
@@ -1165,6 +1165,98 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"set_permissions not specified", response.content) self.assertIn(b"set_permissions not specified", response.content)
m.assert_not_called() m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_dict_value(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {"set_permissions": False},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a dictionary", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_list_users(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"view": {"users": False}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a list", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_unknown_action(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"not_a_real_action": {"users": [1]}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Unknown permission action", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_null_is_a_noop(self, m) -> None:
"""
A `set_permissions: null` value is a deliberate no-op (e.g. a
request that only updates `owner`), not a validation error --
this must keep working even though every other non-dict value is
now rejected.
"""
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": None,
"owner": self.user.id,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_permissions") @mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None: def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions") self.setup_mock(m, "set_permissions")
@@ -1300,7 +1392,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.client.force_authenticate(user=user1) self.client.force_authenticate(user=user1)
permissions = { permissions = {
"owner": user1.id, "view": {"users": [user1.id]},
} }
response = self.client.post( response = self.client.post(
+103
View File
@@ -1499,6 +1499,109 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists()) self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists())
def test_bulk_object_set_permissions_rejects_empty_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an empty permissions dict
THEN:
- Validation fails rather than silently applying a no-op
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_non_dict_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and a non-dict permissions value
THEN:
- Validation fails rather than crashing
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_unknown_action(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an unrecognized permission action name
THEN:
- Validation fails rather than silently no-oping
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"not_a_real_action": {"users": [self.user1.id]}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_null_users_is_a_noop(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an explicit null for users/groups on an action
THEN:
- Request succeeds and is treated as "no users/groups for this
action", not a crash -- the normalized (id-checked) dict
returned by validate_set_permissions must actually be used,
not discarded in favor of the raw un-normalized input.
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"view": {"users": None}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_bulk_edit_object_permissions_validation(self) -> None: def test_bulk_edit_object_permissions_validation(self) -> None:
""" """
GIVEN: GIVEN: