Skip to content

Commit 5b17feb

Browse files
sampaccoudPanchoutNathan
authored andcommitted
♻️(backend) stop requiring owner for non-root documents
If root documents are guaranteed to have a owner, non-root documents will automatically have them as owner by inheritance. We should not require non-root documents to have their own direct owner because this will make it difficult to manage access rights when we move documents around or when we want to remove access rights for someone on a document subtree... There should be as few overrides as possible.
1 parent a4758ff commit 5b17feb

File tree

7 files changed

+298
-74
lines changed

7 files changed

+298
-74
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ and this project adheres to
9898

9999
## Changed
100100

101+
- ♻️(backend) stop requiring owner for non-root documents #846
101102
- ♻️(backend) simplify roles by ranking them and return only the max role #846
102103
- ⚡️(frontend) reduce unblocking time for config #867
103104
- ♻️(frontend) bind UI with ability access #900

src/backend/core/api/viewsets.py

Lines changed: 24 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -234,43 +234,6 @@ def get_serializer_context(self):
234234
context["resource_id"] = self.kwargs["resource_id"]
235235
return context
236236

237-
def destroy(self, request, *args, **kwargs):
238-
"""Forbid deleting the last owner access"""
239-
instance = self.get_object()
240-
resource = getattr(instance, self.resource_field_name)
241-
242-
# Check if the access being deleted is the last owner access for the resource
243-
if (
244-
instance.role == "owner"
245-
and resource.accesses.filter(role="owner").count() == 1
246-
):
247-
return drf.response.Response(
248-
{"detail": "Cannot delete the last owner access for the resource."},
249-
status=drf.status.HTTP_403_FORBIDDEN,
250-
)
251-
252-
return super().destroy(request, *args, **kwargs)
253-
254-
def perform_update(self, serializer):
255-
"""Check that we don't change the role if it leads to losing the last owner."""
256-
instance = serializer.instance
257-
258-
# Check if the role is being updated and the new role is not "owner"
259-
if (
260-
"role" in self.request.data
261-
and self.request.data["role"] != models.RoleChoices.OWNER
262-
):
263-
resource = getattr(instance, self.resource_field_name)
264-
# Check if the access being updated is the last owner access for the resource
265-
if (
266-
instance.role == models.RoleChoices.OWNER
267-
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
268-
):
269-
message = "Cannot change the role to a non-owner role for the last owner access."
270-
raise drf.exceptions.PermissionDenied({"detail": message})
271-
272-
serializer.save()
273-
274237

275238
class DocumentMetadata(drf.metadata.SimpleMetadata):
276239
"""Custom metadata class to add information"""
@@ -649,7 +612,7 @@ def move(self, request, *args, **kwargs):
649612

650613
position = validated_data["position"]
651614
message = None
652-
615+
owner_accesses = []
653616
if position in [
654617
enums.MoveNodePositionChoices.FIRST_CHILD,
655618
enums.MoveNodePositionChoices.LAST_CHILD,
@@ -659,12 +622,15 @@ def move(self, request, *args, **kwargs):
659622
"You do not have permission to move documents "
660623
"as a child to this target document."
661624
)
662-
elif not target_document.is_root():
663-
if not target_document.get_parent().get_abilities(user).get("move"):
664-
message = (
665-
"You do not have permission to move documents "
666-
"as a sibling of this target document."
667-
)
625+
elif target_document.is_root():
626+
owner_accesses = document.get_root().accesses.filter(
627+
role=models.RoleChoices.OWNER
628+
)
629+
elif not target_document.get_parent().get_abilities(user).get("move"):
630+
message = (
631+
"You do not have permission to move documents "
632+
"as a sibling of this target document."
633+
)
668634

669635
if message:
670636
return drf.response.Response(
@@ -674,6 +640,19 @@ def move(self, request, *args, **kwargs):
674640

675641
document.move(target_document, pos=position)
676642

643+
# Make sure we have at least one owner
644+
if (
645+
owner_accesses
646+
and not document.accesses.filter(role=models.RoleChoices.OWNER).exists()
647+
):
648+
for owner_access in owner_accesses:
649+
models.DocumentAccess.objects.update_or_create(
650+
document=document,
651+
user=owner_access.user,
652+
team=owner_access.team,
653+
defaults={"role": models.RoleChoices.OWNER},
654+
)
655+
677656
return drf.response.Response(
678657
{"message": "Document moved successfully."}, status=status.HTTP_200_OK
679658
)
@@ -720,11 +699,7 @@ def children(self, request, *args, **kwargs):
720699
creator=request.user,
721700
**serializer.validated_data,
722701
)
723-
models.DocumentAccess.objects.create(
724-
document=child_document,
725-
user=request.user,
726-
role=models.RoleChoices.OWNER,
727-
)
702+
728703
# Set the created instance to the serializer
729704
serializer.instance = child_document
730705

src/backend/core/models.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,9 +1120,12 @@ def get_abilities(self, user):
11201120
is_owner_or_admin = role in PRIVILEGED_ROLES
11211121

11221122
if self.role == RoleChoices.OWNER:
1123-
can_delete = (
1124-
role == RoleChoices.OWNER
1125-
and DocumentAccess.objects.filter(
1123+
can_delete = role == RoleChoices.OWNER and (
1124+
# check if document is not root trying to avoid an extra query
1125+
# "document_path" is annotated by the viewset's list method
1126+
len(getattr(self, "document_path", "")) > Document.steplen
1127+
or not self.document.is_root()
1128+
or DocumentAccess.objects.filter(
11261129
document_id=self.document_id, role=RoleChoices.OWNER
11271130
).count()
11281131
> 1

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

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,7 @@ def test_api_document_accesses_list_authenticated_related_privileged(
224224
other_access = factories.UserDocumentAccessFactory(user=user)
225225
factories.UserDocumentAccessFactory(document=other_access.document)
226226

227-
nb_queries = 3
228-
if role == "owner":
229-
# Queries that secure the owner status
230-
nb_queries += sum(acc.role == "owner" for acc in document_accesses)
231-
232-
with django_assert_num_queries(nb_queries):
227+
with django_assert_num_queries(3):
233228
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
234229

235230
assert response.status_code == 200
@@ -335,7 +330,12 @@ def test_api_document_accesses_retrieve_set_role_to_child():
335330
],
336331
[
337332
["owner", "reader", "reader", "reader"],
338-
[[], [], [], ["reader", "editor", "administrator", "owner"]],
333+
[
334+
["reader", "editor", "administrator", "owner"],
335+
[],
336+
[],
337+
["reader", "editor", "administrator", "owner"],
338+
],
339339
],
340340
[
341341
["owner", "reader", "reader", "owner"],
@@ -412,7 +412,12 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
412412
],
413413
[
414414
["owner", "reader", "reader", "reader"],
415-
[[], [], [], ["reader", "editor", "administrator", "owner"]],
415+
[
416+
["reader", "editor", "administrator", "owner"],
417+
[],
418+
[],
419+
["reader", "editor", "administrator", "owner"],
420+
],
416421
],
417422
[
418423
["owner", "reader", "reader", "owner"],
@@ -425,7 +430,12 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
425430
],
426431
[
427432
["reader", "reader", "reader", "owner"],
428-
[["reader", "editor", "administrator", "owner"], [], [], []],
433+
[
434+
["reader", "editor", "administrator", "owner"],
435+
[],
436+
[],
437+
["reader", "editor", "administrator", "owner"],
438+
],
429439
],
430440
[
431441
["reader", "administrator", "reader", "editor"],
@@ -929,7 +939,7 @@ def test_api_document_accesses_update_owner(
929939

930940

931941
@pytest.mark.parametrize("via", VIA)
932-
def test_api_document_accesses_update_owner_self(
942+
def test_api_document_accesses_update_owner_self_root(
933943
via,
934944
mock_user_teams,
935945
mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -990,6 +1000,51 @@ def test_api_document_accesses_update_owner_self(
9901000
assert access.role == new_role
9911001

9921002

1003+
@pytest.mark.parametrize("via", VIA)
1004+
def test_api_document_accesses_update_owner_self_child(
1005+
via,
1006+
mock_user_teams,
1007+
mock_reset_connections, # pylint: disable=redefined-outer-name
1008+
):
1009+
"""
1010+
A user who is owner of a document should be allowed to update
1011+
their own user access even if they are the only owner in the document,
1012+
provided the document is not a root.
1013+
"""
1014+
user = factories.UserFactory(with_owned_document=True)
1015+
1016+
client = APIClient()
1017+
client.force_login(user)
1018+
1019+
parent = factories.DocumentFactory()
1020+
document = factories.DocumentFactory(parent=parent)
1021+
access = None
1022+
if via == USER:
1023+
access = factories.UserDocumentAccessFactory(
1024+
document=document, user=user, role="owner"
1025+
)
1026+
elif via == TEAM:
1027+
mock_user_teams.return_value = ["lasuite", "unknown"]
1028+
access = factories.TeamDocumentAccessFactory(
1029+
document=document, team="lasuite", role="owner"
1030+
)
1031+
1032+
old_values = serializers.DocumentAccessSerializer(instance=access).data
1033+
new_role = random.choice(["administrator", "editor", "reader"])
1034+
1035+
user_id = str(access.user_id) if via == USER else None
1036+
with mock_reset_connections(document.id, user_id):
1037+
response = client.put(
1038+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
1039+
data={**old_values, "role": new_role},
1040+
format="json",
1041+
)
1042+
1043+
assert response.status_code == 200
1044+
access.refresh_from_db()
1045+
assert access.role == new_role
1046+
1047+
9931048
# Delete
9941049

9951050

@@ -1170,17 +1225,16 @@ def test_api_document_accesses_delete_owners(
11701225
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
11711226
)
11721227

1173-
assert response.status_code == 204
1174-
assert models.DocumentAccess.objects.count() == 1
1228+
assert response.status_code == 204
1229+
assert models.DocumentAccess.objects.count() == 1
11751230

11761231

11771232
@pytest.mark.parametrize("via", VIA)
1178-
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
1233+
def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams):
11791234
"""
1180-
It should not be possible to delete the last owner access from a document
1235+
It should not be possible to delete the last owner access from a root document
11811236
"""
11821237
user = factories.UserFactory(with_owned_document=True)
1183-
11841238
client = APIClient()
11851239
client.force_login(user)
11861240

@@ -1203,3 +1257,63 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
12031257

12041258
assert response.status_code == 403
12051259
assert models.DocumentAccess.objects.count() == 2
1260+
1261+
1262+
def test_api_document_accesses_delete_owners_last_owner_child_user(
1263+
mock_reset_connections, # pylint: disable=redefined-outer-name
1264+
):
1265+
"""
1266+
It should be possible to delete the last owner access from a document that is not a root.
1267+
"""
1268+
user = factories.UserFactory(with_owned_document=True)
1269+
client = APIClient()
1270+
client.force_login(user)
1271+
1272+
parent = factories.DocumentFactory()
1273+
document = factories.DocumentFactory(parent=parent)
1274+
access = None
1275+
access = factories.UserDocumentAccessFactory(
1276+
document=document, user=user, role="owner"
1277+
)
1278+
1279+
assert models.DocumentAccess.objects.count() == 2
1280+
with mock_reset_connections(document.id, str(access.user_id)):
1281+
response = client.delete(
1282+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
1283+
)
1284+
1285+
assert response.status_code == 204
1286+
assert models.DocumentAccess.objects.count() == 1
1287+
1288+
1289+
@pytest.mark.skip(
1290+
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
1291+
)
1292+
def test_api_document_accesses_delete_owners_last_owner_child_team(
1293+
mock_user_teams,
1294+
mock_reset_connections, # pylint: disable=redefined-outer-name
1295+
):
1296+
"""
1297+
It should be possible to delete the last owner access from a document that
1298+
is not a root.
1299+
"""
1300+
user = factories.UserFactory(with_owned_document=True)
1301+
client = APIClient()
1302+
client.force_login(user)
1303+
1304+
parent = factories.DocumentFactory()
1305+
document = factories.DocumentFactory(parent=parent)
1306+
access = None
1307+
mock_user_teams.return_value = ["lasuite", "unknown"]
1308+
access = factories.TeamDocumentAccessFactory(
1309+
document=document, team="lasuite", role="owner"
1310+
)
1311+
1312+
assert models.DocumentAccess.objects.count() == 2
1313+
with mock_reset_connections(document.id, str(access.user_id)):
1314+
response = client.delete(
1315+
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
1316+
)
1317+
1318+
assert response.status_code == 204
1319+
assert models.DocumentAccess.objects.count() == 1

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
114114
child = Document.objects.get(id=response.json()["id"])
115115
assert child.title == "my child"
116116
assert child.link_reach == "restricted"
117-
assert child.accesses.filter(role="owner", user=user).exists()
117+
# Access objects on the child are not necessary
118+
assert child.accesses.exists() is False
118119

119120

120121
@pytest.mark.parametrize("depth", [1, 2, 3])
@@ -182,7 +183,8 @@ def test_api_documents_children_create_related_success(role, depth):
182183
child = Document.objects.get(id=response.json()["id"])
183184
assert child.title == "my child"
184185
assert child.link_reach == "restricted"
185-
assert child.accesses.filter(role="owner", user=user).exists()
186+
# Access objects on the child are not necessary
187+
assert child.accesses.exists() is False
186188

187189

188190
def test_api_documents_children_create_authenticated_title_null():

0 commit comments

Comments
 (0)