Skip to content

Commit 8e097e9

Browse files
committed
♻️(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 883be71 commit 8e097e9

7 files changed

+188
-31
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to
2525

2626
## Changed
2727

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

src/backend/core/api/viewsets.py

+24-12
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ def move(self, request, *args, **kwargs):
645645

646646
position = validated_data["position"]
647647
message = None
648-
648+
owner_accesses = []
649649
if position in [
650650
enums.MoveNodePositionChoices.FIRST_CHILD,
651651
enums.MoveNodePositionChoices.LAST_CHILD,
@@ -655,12 +655,15 @@ def move(self, request, *args, **kwargs):
655655
"You do not have permission to move documents "
656656
"as a child to this target document."
657657
)
658-
elif not target_document.is_root():
659-
if not target_document.get_parent().get_abilities(user).get("move"):
660-
message = (
661-
"You do not have permission to move documents "
662-
"as a sibling of this target document."
663-
)
658+
elif target_document.is_root():
659+
owner_accesses = document.get_root().accesses.filter(
660+
role=models.RoleChoices.OWNER
661+
)
662+
elif not target_document.get_parent().get_abilities(user).get("move"):
663+
message = (
664+
"You do not have permission to move documents "
665+
"as a sibling of this target document."
666+
)
664667

665668
if message:
666669
return drf.response.Response(
@@ -670,6 +673,19 @@ def move(self, request, *args, **kwargs):
670673

671674
document.move(target_document, pos=position)
672675

676+
# Make sure we have at least one owner
677+
if (
678+
owner_accesses
679+
and not document.accesses.filter(role=models.RoleChoices.OWNER).exists()
680+
):
681+
for owner_access in owner_accesses:
682+
models.DocumentAccess.objects.update_or_create(
683+
document=document,
684+
user=owner_access.user,
685+
team=owner_access.team,
686+
defaults={"role": models.RoleChoices.OWNER},
687+
)
688+
673689
return drf.response.Response(
674690
{"message": "Document moved successfully."}, status=status.HTTP_200_OK
675691
)
@@ -716,11 +732,7 @@ def children(self, request, *args, **kwargs):
716732
creator=request.user,
717733
**serializer.validated_data,
718734
)
719-
models.DocumentAccess.objects.create(
720-
document=child_document,
721-
user=request.user,
722-
role=models.RoleChoices.OWNER,
723-
)
735+
724736
# Set the created instance to the serializer
725737
serializer.instance = child_document
726738

src/backend/core/models.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -1093,9 +1093,12 @@ def get_abilities(self, user):
10931093
is_owner_or_admin = role in PRIVILEGED_ROLES
10941094

10951095
if self.role == RoleChoices.OWNER:
1096-
can_delete = (
1097-
role == RoleChoices.OWNER
1098-
and DocumentAccess.objects.filter(
1096+
can_delete = role == RoleChoices.OWNER and (
1097+
# check if document is not root trying to avoid an extra query
1098+
# "document_path" is annotated by the viewset's list method
1099+
len(getattr(self, "document_path", "")) > Document.steplen
1100+
or not self.document.is_root()
1101+
or DocumentAccess.objects.filter(
10991102
document_id=self.document_id, role=RoleChoices.OWNER
11001103
).count()
11011104
> 1

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

+19-9
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,7 @@ def test_api_document_accesses_list_authenticated_related_privileged(
226226
other_access = factories.UserDocumentAccessFactory(user=user)
227227
factories.UserDocumentAccessFactory(document=other_access.document)
228228

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

237232
assert response.status_code == 200
@@ -278,7 +273,12 @@ def test_api_document_accesses_list_authenticated_related_privileged(
278273
],
279274
[
280275
["owner", "reader", "reader", "reader"],
281-
[[], [], [], ["reader", "editor", "administrator", "owner"]],
276+
[
277+
["reader", "editor", "administrator", "owner"],
278+
[],
279+
[],
280+
["reader", "editor", "administrator", "owner"],
281+
],
282282
],
283283
[
284284
["owner", "reader", "reader", "owner"],
@@ -355,7 +355,12 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
355355
],
356356
[
357357
["owner", "reader", "reader", "reader"],
358-
[[], [], [], ["reader", "editor", "administrator", "owner"]],
358+
[
359+
["reader", "editor", "administrator", "owner"],
360+
[],
361+
[],
362+
["reader", "editor", "administrator", "owner"],
363+
],
359364
],
360365
[
361366
["owner", "reader", "reader", "owner"],
@@ -368,7 +373,12 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
368373
],
369374
[
370375
["reader", "reader", "reader", "owner"],
371-
[["reader", "editor", "administrator", "owner"], [], [], []],
376+
[
377+
["reader", "editor", "administrator", "owner"],
378+
[],
379+
[],
380+
["reader", "editor", "administrator", "owner"],
381+
],
372382
],
373383
[
374384
["reader", "administrator", "reader", "editor"],

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

+4-2
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():

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

+103-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked(
124124
target_role, target_parent_role, position
125125
):
126126
"""
127-
Authenticated users with insufficient permissions on the target document (or its
128-
parent depending on the position chosen), should not be allowed to move documents.
127+
Only authenticated users with sufficient permissions on the target document (or its
128+
parent depending on the position chosen), should be allowed to move documents.
129129
"""
130130

131131
user = factories.UserFactory()
@@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked(
208208
assert document.is_root() is True
209209

210210

211+
def test_api_documents_move_authenticated_no_owner_user_and_team():
212+
"""
213+
Moving a document with no owner to the root of the tree should automatically declare
214+
the owner of the prevous root of the document as owner of the document itself.
215+
"""
216+
user = factories.UserFactory()
217+
client = APIClient()
218+
client.force_login(user)
219+
220+
parent_owner = factories.UserFactory()
221+
parent = factories.DocumentFactory(
222+
users=[(parent_owner, "owner")], teams=[("lasuite", "owner")]
223+
)
224+
# A document with no owner
225+
document = factories.DocumentFactory(parent=parent, users=[(user, "administrator")])
226+
child = factories.DocumentFactory(parent=document)
227+
target = factories.DocumentFactory()
228+
229+
response = client.post(
230+
f"/api/v1.0/documents/{document.id!s}/move/",
231+
data={"target_document_id": str(target.id), "position": "first-sibling"},
232+
)
233+
234+
assert response.status_code == 200
235+
assert response.json() == {"message": "Document moved successfully."}
236+
assert list(target.get_siblings()) == [document, parent, target]
237+
238+
document.refresh_from_db()
239+
assert list(document.get_children()) == [child]
240+
assert document.accesses.count() == 3
241+
assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner
242+
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
243+
assert document.accesses.get(role="administrator").user == user
244+
245+
246+
def test_api_documents_move_authenticated_no_owner_same_user():
247+
"""
248+
Moving a document should not fail if the user moving a document with no owner was
249+
at the same time owner of the previous root and has a role on the document being moved.
250+
"""
251+
user = factories.UserFactory()
252+
client = APIClient()
253+
client.force_login(user)
254+
255+
parent = factories.DocumentFactory(
256+
users=[(user, "owner")], teams=[("lasuite", "owner")]
257+
)
258+
# A document with no owner
259+
document = factories.DocumentFactory(parent=parent, users=[(user, "reader")])
260+
child = factories.DocumentFactory(parent=document)
261+
target = factories.DocumentFactory()
262+
263+
response = client.post(
264+
f"/api/v1.0/documents/{document.id!s}/move/",
265+
data={"target_document_id": str(target.id), "position": "first-sibling"},
266+
)
267+
268+
assert response.status_code == 200
269+
assert response.json() == {"message": "Document moved successfully."}
270+
assert list(target.get_siblings()) == [document, parent, target]
271+
272+
document.refresh_from_db()
273+
assert list(document.get_children()) == [child]
274+
assert document.accesses.count() == 2
275+
assert document.accesses.get(user__isnull=False, role="owner").user == user
276+
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
277+
278+
279+
def test_api_documents_move_authenticated_no_owner_same_team():
280+
"""
281+
Moving a document should not fail if the team that is owner of the document root was
282+
already declared on the document with a different role.
283+
"""
284+
user = factories.UserFactory()
285+
client = APIClient()
286+
client.force_login(user)
287+
288+
parent = factories.DocumentFactory(teams=[("lasuite", "owner")])
289+
# A document with no owner but same team
290+
document = factories.DocumentFactory(
291+
parent=parent, users=[(user, "administrator")], teams=[("lasuite", "reader")]
292+
)
293+
child = factories.DocumentFactory(parent=document)
294+
target = factories.DocumentFactory()
295+
296+
response = client.post(
297+
f"/api/v1.0/documents/{document.id!s}/move/",
298+
data={"target_document_id": str(target.id), "position": "first-sibling"},
299+
)
300+
301+
assert response.status_code == 200
302+
assert response.json() == {"message": "Document moved successfully."}
303+
assert list(target.get_siblings()) == [document, parent, target]
304+
305+
document.refresh_from_db()
306+
assert list(document.get_children()) == [child]
307+
assert document.accesses.count() == 2
308+
assert document.accesses.get(user__isnull=False, role="administrator").user == user
309+
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
310+
311+
211312
def test_api_documents_move_authenticated_deleted_document():
212313
"""
213314
It should not be possible to move a deleted document or its descendants, even

src/backend/core/tests/test_models_document_accesses.py

+31-3
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,18 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
127127
}
128128

129129

130-
def test_models_document_access_get_abilities_for_owner_of_self_last():
130+
def test_models_document_access_get_abilities_for_owner_of_self_last_on_root(
131+
django_assert_num_queries,
132+
):
131133
"""
132-
Check abilities of self access for the owner of a document when there is only one owner left.
134+
Check abilities of self access for the owner of a root document when there
135+
is only one owner left.
133136
"""
134137
access = factories.UserDocumentAccessFactory(role="owner")
135-
abilities = access.get_abilities(access.user)
138+
139+
with django_assert_num_queries(2):
140+
abilities = access.get_abilities(access.user)
141+
136142
assert abilities == {
137143
"destroy": False,
138144
"retrieve": True,
@@ -142,6 +148,28 @@ def test_models_document_access_get_abilities_for_owner_of_self_last():
142148
}
143149

144150

151+
def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
152+
django_assert_num_queries,
153+
):
154+
"""
155+
Check abilities of self access for the owner of a child document when there
156+
is only one owner left.
157+
"""
158+
parent = factories.DocumentFactory()
159+
access = factories.UserDocumentAccessFactory(document__parent=parent, role="owner")
160+
161+
with django_assert_num_queries(1):
162+
abilities = access.get_abilities(access.user)
163+
164+
assert abilities == {
165+
"destroy": True,
166+
"retrieve": True,
167+
"update": True,
168+
"partial_update": True,
169+
"set_role_to": ["reader", "editor", "administrator", "owner"],
170+
}
171+
172+
145173
def test_models_document_access_get_abilities_for_owner_of_owner():
146174
"""Check abilities of owner access for the owner of a document."""
147175
access = factories.UserDocumentAccessFactory(role="owner")

0 commit comments

Comments
 (0)