Skip to content

Commit 2286b02

Browse files
committed
🚸(backend) validate document access roles on submit
The frontend should only let users choose override roles that make sense.
1 parent 66f0b57 commit 2286b02

File tree

3 files changed

+288
-16
lines changed

3 files changed

+288
-16
lines changed

src/backend/core/api/serializers.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,12 +339,53 @@ def get_max_ancestors_role(self, instance):
339339
return getattr(instance, "max_ancestors_role", None)
340340

341341
def get_max_role(self, instance):
342-
"""Return max_ancestors_role if annotated; else None."""
342+
"""Return max role."""
343343
return choices.RoleChoices.max(
344344
getattr(instance, "max_ancestors_role", None),
345345
instance.role,
346346
)
347347

348+
def validate(self, attrs):
349+
"""
350+
Ensure the selected role is greater than or equal to the maximum role
351+
found in any ancestor document for the same user/team.
352+
"""
353+
354+
if self.instance:
355+
document = self.instance.document
356+
user = self.instance.user
357+
team = self.instance.team
358+
else:
359+
document_id = self.context["resource_id"]
360+
document = models.Document.objects.get(id=document_id)
361+
team = attrs.get("team")
362+
user = attrs.get("user")
363+
364+
ancestors = document.get_ancestors().filter(ancestors_deleted_at__isnull=True)
365+
access_qs = models.DocumentAccess.objects.filter(document__in=ancestors)
366+
367+
if user:
368+
access_qs = access_qs.filter(user=user)
369+
if team:
370+
access_qs = access_qs.filter(team=team)
371+
372+
ancestor_roles = access_qs.values_list("role", flat=True)
373+
inherited_role = choices.RoleChoices.max(*ancestor_roles)
374+
375+
role = attrs.get("role")
376+
get_priority = choices.RoleChoices.get_priority
377+
if inherited_role and get_priority(inherited_role) >= get_priority(role):
378+
raise serializers.ValidationError(
379+
{
380+
"role": (
381+
"Role overrides must be greater than the inherited role: "
382+
f"{inherited_role}/{role}"
383+
)
384+
}
385+
)
386+
387+
return super().validate(attrs)
388+
348389
def update(self, instance, validated_data):
349390
"""Make "user" field readonly but only on update."""
350391
validated_data.pop("team", None)

src/backend/core/tests/documents/test_api_document_accesses.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
140140
{
141141
"id": str(access.id),
142142
"document": {
143+
"depth": access.document.depth,
143144
"id": str(access.document_id),
144145
"path": access.document.path,
145-
"depth": access.document.depth,
146146
},
147147
"user": {
148148
"full_name": access.user.full_name,
@@ -240,9 +240,9 @@ def test_api_document_accesses_list_authenticated_related_privileged(
240240
{
241241
"id": str(access.id),
242242
"document": {
243+
"depth": access.document.depth,
243244
"id": str(access.document_id),
244245
"path": access.document.path,
245-
"depth": access.document.depth,
246246
},
247247
"user": {
248248
"id": str(access.user.id),
@@ -611,14 +611,15 @@ def test_api_document_accesses_retrieve_authenticated_related(
611611
"id": str(access.id),
612612
"abilities": access.get_abilities(user),
613613
"document": {
614+
"depth": access.document.depth,
614615
"id": str(access.document_id),
615616
"path": access.document.path,
616-
"depth": access.document.depth,
617617
},
618618
"user": access_user,
619619
"team": "",
620620
"role": access.role,
621621
"max_ancestors_role": None,
622+
"max_role": access.role,
622623
}
623624

624625

@@ -963,6 +964,119 @@ def test_api_document_accesses_update_owner(
963964
assert updated_values == old_values
964965

965966

967+
@pytest.mark.parametrize("new_override_role", choices.RoleChoices.values)
968+
@pytest.mark.parametrize("parent_role", choices.RoleChoices.values)
969+
def test_api_document_accesses_update_higher_role_to_user(
970+
parent_role,
971+
new_override_role,
972+
mock_reset_connections, # pylint: disable=redefined-outer-name
973+
):
974+
"""
975+
It should not be allowed to update the role of a document access override
976+
for a user with a role lower or equal to the inherited role.
977+
"""
978+
user, other_user = factories.UserFactory.create_batch(2)
979+
980+
client = APIClient()
981+
client.force_login(user)
982+
983+
parent = factories.DocumentFactory(
984+
users=[[user, "owner"], [other_user, parent_role]]
985+
)
986+
document = factories.DocumentFactory(parent=parent)
987+
988+
override_role = random.choice(choices.RoleChoices.values)
989+
access = factories.UserDocumentAccessFactory(
990+
document=document, user=other_user, role=override_role
991+
)
992+
993+
get_priority = choices.RoleChoices.get_priority
994+
if get_priority(new_override_role) > get_priority(parent_role):
995+
with mock_reset_connections(document.id, str(access.user_id)):
996+
response = client.put(
997+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
998+
data={"role": new_override_role},
999+
format="json",
1000+
)
1001+
1002+
assert response.status_code == 200
1003+
access.refresh_from_db()
1004+
assert access.role == new_override_role
1005+
else:
1006+
response = client.put(
1007+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
1008+
data={"role": new_override_role},
1009+
format="json",
1010+
)
1011+
assert response.status_code == 400
1012+
access.refresh_from_db()
1013+
assert access.role == override_role
1014+
assert response.json() == {
1015+
"role": [
1016+
"Role overrides must be greater than the inherited role: "
1017+
f"{parent_role}/{new_override_role}"
1018+
],
1019+
}
1020+
1021+
1022+
@pytest.mark.skip(
1023+
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
1024+
)
1025+
@pytest.mark.parametrize("new_override_role", choices.RoleChoices.values)
1026+
@pytest.mark.parametrize("parent_role", choices.RoleChoices.values)
1027+
def test_api_document_accesses_update_higher_role_to_team(
1028+
parent_role,
1029+
new_override_role,
1030+
mock_reset_connections, # pylint: disable=redefined-outer-name
1031+
):
1032+
"""
1033+
It should not be allowed to update the role of a document access override
1034+
for a team with a role lower or equal to the inherited role.
1035+
"""
1036+
user = factories.UserFactory()
1037+
1038+
client = APIClient()
1039+
client.force_login(user)
1040+
1041+
parent = factories.DocumentFactory(
1042+
users=[[user, "owner"]], teams=[["lasuite", parent_role]]
1043+
)
1044+
document = factories.DocumentFactory(parent=parent)
1045+
1046+
override_role = random.choice(choices.RoleChoices.values)
1047+
access = factories.TeamDocumentAccessFactory(
1048+
document=document, team="lasuite", role=override_role
1049+
)
1050+
1051+
get_priority = choices.RoleChoices.get_priority
1052+
if get_priority(new_override_role) > get_priority(parent_role):
1053+
with mock_reset_connections(document.id, str(access.user_id)):
1054+
response = client.put(
1055+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
1056+
data={"role": new_override_role},
1057+
format="json",
1058+
)
1059+
1060+
assert response.status_code == 200
1061+
access.refresh_from_db()
1062+
assert access.role == new_override_role
1063+
else:
1064+
response = client.put(
1065+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
1066+
data={"role": new_override_role},
1067+
format="json",
1068+
)
1069+
assert response.status_code == 400
1070+
access.refresh_from_db()
1071+
assert access.role == override_role
1072+
assert response.json() == {
1073+
"role": [
1074+
"Role overrides must be greater than the inherited role: "
1075+
f"{parent_role}/{new_override_role}"
1076+
],
1077+
}
1078+
1079+
9661080
@pytest.mark.parametrize("via", VIA)
9671081
def test_api_document_accesses_update_owner_self_root(
9681082
via,

0 commit comments

Comments
 (0)