Skip to content

Commit 1ed01fd

Browse files
committed
🥅(backend) link role could be updated when restricted document
When a document was restricted, the link role could be updated from "link-configuration" and gives a 200 response, but the change did not have any effect because of a restriction in LinkReachChoices. We added a validation step to ensure that the link role can only be updated if the document is not restricted.
1 parent e4aa85b commit 1ed01fd

File tree

2 files changed

+301
-2
lines changed

2 files changed

+301
-2
lines changed

src/backend/core/api/serializers.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,13 +506,69 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
506506
We expose it separately from document in order to simplify and secure access control.
507507
"""
508508

509+
link_reach = serializers.ChoiceField(
510+
choices=models.LinkReachChoices.choices, required=True
511+
)
512+
509513
class Meta:
510514
model = models.Document
511515
fields = [
512516
"link_role",
513517
"link_reach",
514518
]
515519

520+
def validate(self, attrs):
521+
"""Validate that link_role and link_reach are compatible using get_select_options."""
522+
link_reach = attrs.get("link_reach")
523+
link_role = attrs.get("link_role")
524+
525+
if not link_reach:
526+
raise serializers.ValidationError(
527+
{"link_reach": _("This field is required.")}
528+
)
529+
530+
# Get available options based on ancestors' link definition
531+
available_options = models.LinkReachChoices.get_select_options(
532+
**self.instance.ancestors_link_definition
533+
)
534+
535+
# Validate link_reach is allowed
536+
if link_reach not in available_options:
537+
msg = _(
538+
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
539+
)
540+
raise serializers.ValidationError(
541+
{"link_reach": msg % {"link_reach": link_reach}}
542+
)
543+
544+
# Validate link_role is compatible with link_reach
545+
allowed_roles = available_options[link_reach]
546+
547+
# Restricted reach: link_role must be None
548+
if link_reach == models.LinkReachChoices.RESTRICTED:
549+
if link_role is not None:
550+
raise serializers.ValidationError(
551+
{
552+
"link_role": (
553+
"Cannot set link_role when link_reach is 'restricted'. "
554+
"Link role must be null for restricted reach."
555+
)
556+
}
557+
)
558+
return attrs
559+
# Non-restricted: link_role must be in allowed roles
560+
if link_role not in allowed_roles:
561+
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
562+
raise serializers.ValidationError(
563+
{
564+
"link_role": (
565+
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
566+
f"Allowed roles: {allowed_roles_str}"
567+
)
568+
}
569+
)
570+
return attrs
571+
516572

517573
class DocumentDuplicationSerializer(serializers.Serializer):
518574
"""

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

Lines changed: 245 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
133133
client = APIClient()
134134
client.force_login(user)
135135

136-
document = factories.DocumentFactory()
136+
document = factories.DocumentFactory(
137+
link_reach=models.LinkReachChoices.AUTHENTICATED,
138+
link_role=models.LinkRoleChoices.READER,
139+
)
137140
if via == USER:
138141
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
139142
elif via == TEAM:
@@ -143,7 +146,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
143146
)
144147

145148
new_document_values = serializers.LinkDocumentSerializer(
146-
instance=factories.DocumentFactory()
149+
instance=factories.DocumentFactory(
150+
link_reach=models.LinkReachChoices.PUBLIC,
151+
link_role=models.LinkRoleChoices.EDITOR,
152+
)
147153
).data
148154

149155
with mock_reset_connections(document.id):
@@ -158,3 +164,240 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
158164
document_values = serializers.LinkDocumentSerializer(instance=document).data
159165
for key, value in document_values.items():
160166
assert value == new_document_values[key]
167+
168+
169+
def test_api_documents_link_configuration_update_role_restricted_forbidden():
170+
"""
171+
Test that trying to set link_role on a document with restricted link_reach
172+
returns a validation error.
173+
"""
174+
user = factories.UserFactory()
175+
client = APIClient()
176+
client.force_login(user)
177+
178+
document = factories.DocumentFactory(
179+
link_reach=models.LinkReachChoices.RESTRICTED,
180+
link_role=models.LinkRoleChoices.READER,
181+
)
182+
183+
factories.UserDocumentAccessFactory(
184+
document=document, user=user, role=models.RoleChoices.OWNER
185+
)
186+
187+
# Try to set a meaningful role on a restricted document
188+
new_data = {
189+
"link_reach": models.LinkReachChoices.RESTRICTED,
190+
"link_role": models.LinkRoleChoices.EDITOR,
191+
}
192+
193+
response = client.put(
194+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
195+
new_data,
196+
format="json",
197+
)
198+
199+
assert response.status_code == 400
200+
assert "link_role" in response.json()
201+
assert (
202+
"Cannot set link_role when link_reach is 'restricted'"
203+
in response.json()["link_role"][0]
204+
)
205+
206+
207+
def test_api_documents_link_configuration_update_link_reach_required():
208+
"""
209+
Test that link_reach is required when updating link configuration.
210+
"""
211+
user = factories.UserFactory()
212+
client = APIClient()
213+
client.force_login(user)
214+
215+
document = factories.DocumentFactory(
216+
link_reach=models.LinkReachChoices.PUBLIC,
217+
link_role=models.LinkRoleChoices.READER,
218+
)
219+
220+
factories.UserDocumentAccessFactory(
221+
document=document, user=user, role=models.RoleChoices.OWNER
222+
)
223+
224+
# Try to update without providing link_reach
225+
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
226+
227+
response = client.put(
228+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
229+
new_data,
230+
format="json",
231+
)
232+
233+
assert response.status_code == 400
234+
assert "link_reach" in response.json()
235+
assert "This field is required" in response.json()["link_reach"][0]
236+
237+
238+
def test_api_documents_link_configuration_update_restricted_without_role_success(
239+
mock_reset_connections, # pylint: disable=redefined-outer-name
240+
):
241+
"""
242+
Test that setting link_reach to restricted without specifying link_role succeeds.
243+
"""
244+
user = factories.UserFactory()
245+
client = APIClient()
246+
client.force_login(user)
247+
248+
document = factories.DocumentFactory(
249+
link_reach=models.LinkReachChoices.PUBLIC,
250+
link_role=models.LinkRoleChoices.READER,
251+
)
252+
253+
factories.UserDocumentAccessFactory(
254+
document=document, user=user, role=models.RoleChoices.OWNER
255+
)
256+
257+
# Only specify link_reach, not link_role
258+
new_data = {
259+
"link_reach": models.LinkReachChoices.RESTRICTED,
260+
}
261+
262+
with mock_reset_connections(document.id):
263+
response = client.put(
264+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
265+
new_data,
266+
format="json",
267+
)
268+
269+
assert response.status_code == 200
270+
document.refresh_from_db()
271+
assert document.link_reach == models.LinkReachChoices.RESTRICTED
272+
273+
274+
@pytest.mark.parametrize(
275+
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
276+
)
277+
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
278+
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
279+
reach,
280+
role,
281+
mock_reset_connections, # pylint: disable=redefined-outer-name
282+
):
283+
"""
284+
Test that setting non-restricted link_reach with valid link_role succeeds.
285+
"""
286+
user = factories.UserFactory()
287+
client = APIClient()
288+
client.force_login(user)
289+
290+
document = factories.DocumentFactory(
291+
link_reach=models.LinkReachChoices.RESTRICTED,
292+
link_role=models.LinkRoleChoices.READER,
293+
)
294+
295+
factories.UserDocumentAccessFactory(
296+
document=document, user=user, role=models.RoleChoices.OWNER
297+
)
298+
299+
new_data = {
300+
"link_reach": reach,
301+
"link_role": role,
302+
}
303+
304+
with mock_reset_connections(document.id):
305+
response = client.put(
306+
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
307+
new_data,
308+
format="json",
309+
)
310+
311+
assert response.status_code == 200
312+
document.refresh_from_db()
313+
assert document.link_reach == reach
314+
assert document.link_role == role
315+
316+
317+
def test_api_documents_link_configuration_update_with_ancestor_constraints():
318+
"""
319+
Test that link configuration respects ancestor constraints using get_select_options.
320+
This test may need adjustment based on the actual get_select_options implementation.
321+
"""
322+
user = factories.UserFactory()
323+
client = APIClient()
324+
client.force_login(user)
325+
326+
parent_document = factories.DocumentFactory(
327+
link_reach=models.LinkReachChoices.PUBLIC,
328+
link_role=models.LinkRoleChoices.READER,
329+
)
330+
331+
child_document = factories.DocumentFactory(
332+
parent=parent_document,
333+
link_reach=models.LinkReachChoices.PUBLIC,
334+
link_role=models.LinkRoleChoices.READER,
335+
)
336+
337+
factories.UserDocumentAccessFactory(
338+
document=child_document, user=user, role=models.RoleChoices.OWNER
339+
)
340+
341+
# Try to set child to PUBLIC when parent is RESTRICTED
342+
new_data = {
343+
"link_reach": models.LinkReachChoices.RESTRICTED,
344+
"link_role": models.LinkRoleChoices.READER,
345+
}
346+
347+
response = client.put(
348+
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
349+
new_data,
350+
format="json",
351+
)
352+
353+
assert response.status_code == 400
354+
assert "link_reach" in response.json()
355+
assert (
356+
"Link reach 'restricted' is not allowed based on parent"
357+
in response.json()["link_reach"][0]
358+
)
359+
360+
361+
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
362+
"""
363+
Test the specific validation logic that checks if link_role is allowed for link_reach.
364+
This tests the code section that validates allowed_roles from get_select_options.
365+
"""
366+
user = factories.UserFactory()
367+
client = APIClient()
368+
client.force_login(user)
369+
370+
parent_document = factories.DocumentFactory(
371+
link_reach=models.LinkReachChoices.AUTHENTICATED,
372+
link_role=models.LinkRoleChoices.EDITOR,
373+
)
374+
375+
child_document = factories.DocumentFactory(
376+
parent=parent_document,
377+
link_reach=models.LinkReachChoices.RESTRICTED,
378+
link_role=models.LinkRoleChoices.READER,
379+
)
380+
381+
factories.UserDocumentAccessFactory(
382+
document=child_document, user=user, role=models.RoleChoices.OWNER
383+
)
384+
385+
new_data = {
386+
"link_reach": models.LinkReachChoices.AUTHENTICATED,
387+
"link_role": models.LinkRoleChoices.READER, # This should be rejected
388+
}
389+
390+
response = client.put(
391+
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
392+
new_data,
393+
format="json",
394+
)
395+
396+
assert response.status_code == 400
397+
assert "link_role" in response.json()
398+
error_message = response.json()["link_role"][0]
399+
assert (
400+
"Link role 'reader' is not allowed for link reach 'authenticated'"
401+
in error_message
402+
)
403+
assert "Allowed roles: editor" in error_message

0 commit comments

Comments
 (0)