diff --git a/CHANGELOG.md b/CHANGELOG.md index 2216184e..30686425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to - ✨(backend) add a local entitlements backend with per-user storage limits - ✨(frontend) add storage gauge and settings modal - ♻️(backend) route permission decisions through a swappable backend +- ✨(backend) add restricted access on folders, detached behind a shortcut ### Fixed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 488c7f57..a1ae9561 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -15,7 +15,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from lasuite.drf.models.choices import LinkReachChoices, get_equivalent_link_definition -from rest_framework import serializers +from rest_framework import exceptions, serializers from core import models from core.api import utils @@ -250,6 +250,7 @@ class ListItemSerializer(serializers.ModelSerializer): "is_favorite", "link_role", "link_reach", + "is_restricted", "nb_accesses", "numchild", "numchild_folder", @@ -282,6 +283,7 @@ class ListItemSerializer(serializers.ModelSerializer): "creator", "depth", "is_favorite", + "is_restricted", "link_role", "link_reach", "nb_accesses", @@ -480,6 +482,7 @@ class ItemSerializer(ListItemSerializer): "is_favorite", "link_role", "link_reach", + "is_restricted", "nb_accesses", "numchild", "numchild_folder", @@ -536,7 +539,19 @@ class ItemSerializer(ListItemSerializer): raise NotImplementedError("Create method can not be used.") def update(self, instance, validated_data): - """Validate that the title is unique in the current path.""" + """Update an item, handling restriction and title uniqueness.""" + is_restricted = validated_data.pop("is_restricted", None) + if is_restricted is not None and is_restricted != instance.is_restricted: + user = self.context["request"].user + if not instance.get_abilities(user).get("restrict"): + raise exceptions.PermissionDenied() + # Toggling restriction moves the item: keep working on the + # returned instance so its refreshed path is not overwritten + if is_restricted: + instance = instance.restrict(user) + else: + instance = instance.unrestrict() + if validated_data.get("title") and instance.title != validated_data.get("title"): if instance.depth > 1: validated_data["title"] = instance.manage_unique_title(validated_data.get("title")) diff --git a/src/backend/core/tests/items/test_api_items_children_list.py b/src/backend/core/tests/items/test_api_items_children_list.py index 5321d138..58476e04 100644 --- a/src/backend/core/tests/items/test_api_items_children_list.py +++ b/src/backend/core/tests/items/test_api_items_children_list.py @@ -47,6 +47,7 @@ def test_api_items_children_list_anonymous_public_standalone(): "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -91,6 +92,7 @@ def test_api_items_children_list_anonymous_public_standalone(): "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -175,6 +177,7 @@ def test_api_items_children_list_anonymous_public_parent(): "depth": 4, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -213,6 +216,7 @@ def test_api_items_children_list_anonymous_public_parent(): "depth": 4, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -305,6 +309,7 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -349,6 +354,7 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -430,6 +436,7 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent( "depth": 4, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -474,6 +481,7 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent( "depth": 4, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -580,6 +588,7 @@ def test_api_items_children_list_authenticated_related_direct(): "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -624,6 +633,7 @@ def test_api_items_children_list_authenticated_related_direct(): "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -708,6 +718,7 @@ def test_api_items_children_list_authenticated_related_parent(): "depth": 4, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -752,6 +763,7 @@ def test_api_items_children_list_authenticated_related_parent(): "depth": 4, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -893,6 +905,7 @@ def test_api_items_children_list_authenticated_related_team_members( "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -937,6 +950,7 @@ def test_api_items_children_list_authenticated_related_team_members( "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -1019,6 +1033,7 @@ def test_api_items_children_list_filter_type(): "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -1072,6 +1087,7 @@ def test_api_items_children_list_filter_type(): "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -1379,6 +1395,7 @@ def test_api_items_children_list_computed_link_reach_and_role(): "depth": 3, "id": str(item.id), "is_favorite": False, + "is_restricted": False, "link_reach": "restricted", "link_role": "reader", "numchild": 1, @@ -1427,6 +1444,7 @@ def test_api_items_children_list_computed_link_reach_and_role(): "depth": 4, "id": str(child.id), "is_favorite": False, + "is_restricted": False, "link_reach": "public", "link_role": "editor", "numchild": 0, diff --git a/src/backend/core/tests/items/test_api_items_list.py b/src/backend/core/tests/items/test_api_items_list.py index b3e01478..b4898f17 100644 --- a/src/backend/core/tests/items/test_api_items_list.py +++ b/src/backend/core/tests/items/test_api_items_list.py @@ -106,6 +106,7 @@ def test_api_items_list_format(): }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": item3.link_reach, "link_role": item3.link_role, "nb_accesses": 0, @@ -144,6 +145,7 @@ def test_api_items_list_format(): }, "depth": 1, "is_favorite": True, + "is_restricted": False, "link_reach": item2.link_reach, "link_role": item2.link_role, "nb_accesses": 3, @@ -182,6 +184,7 @@ def test_api_items_list_format(): }, "depth": 1, "is_favorite": True, + "is_restricted": False, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 3, diff --git a/src/backend/core/tests/items/test_api_items_restrict.py b/src/backend/core/tests/items/test_api_items_restrict.py new file mode 100644 index 00000000..5010eb52 --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_restrict.py @@ -0,0 +1,148 @@ +"""Tests for items API endpoint: restrict / unrestrict via partial update.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_items_restrict_owner_can_activate(): + """An owner can activate restriction on a folder via partial update.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/items/{folder.id!s}/", + {"is_restricted": True}, + format="json", + ) + assert response.status_code == 200 + assert response.json()["is_restricted"] is True + + folder.refresh_from_db() + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + assert folder.shortcut.type == models.ItemTypeChoices.SHORTCUT + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").count() == 1 + + +def test_api_items_restrict_non_owner_cannot_activate(): + """A non-owner cannot activate restriction on a folder.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + users=[(user, "administrator")], + ) + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/items/{folder.id!s}/", + {"is_restricted": True}, + format="json", + ) + assert response.status_code == 403 + + folder.refresh_from_db() + assert folder.is_restricted is False + + +def test_api_items_restrict_owner_cannot_activate_a_root(): + """A root folder cannot be restricted, even by its owner.""" + user = factories.UserFactory() + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/items/{folder.id!s}/", + {"is_restricted": True}, + format="json", + ) + assert response.status_code == 403 + + folder.refresh_from_db() + assert folder.is_restricted is False + + +def test_api_items_restrict_owner_can_deactivate(): + """An owner can deactivate restriction on a folder via partial update.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/items/{folder.id!s}/", + {"is_restricted": False}, + format="json", + ) + assert response.status_code == 200 + assert response.json()["is_restricted"] is False + + folder.refresh_from_db() + assert folder.is_restricted is False + assert str(folder.path) == f"{parent.id!s}.{folder.id!s}" + assert not models.Item.objects.filter(target=folder).exists() + + +def test_api_items_restrict_excluded_owner_cannot_deactivate(): + """A user without explicit access cannot deactivate a restricted folder.""" + parent_owner = factories.UserFactory() + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + client = APIClient() + client.force_login(parent_owner) + + response = client.patch( + f"/api/v1.0/items/{folder.id!s}/", + {"is_restricted": False}, + format="json", + ) + assert response.status_code == 403 + + folder.refresh_from_db() + assert folder.is_restricted is True + + +def test_api_items_restrict_response_includes_field(): + """The is_restricted field is present in the API response.""" + user = factories.UserFactory() + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/items/{folder.id!s}/") + assert response.status_code == 200 + assert "is_restricted" in response.json() + assert response.json()["is_restricted"] is False diff --git a/src/backend/core/tests/items/test_api_items_retrieve.py b/src/backend/core/tests/items/test_api_items_retrieve.py index 30b37318..7151987a 100644 --- a/src/backend/core/tests/items/test_api_items_retrieve.py +++ b/src/backend/core/tests/items/test_api_items_retrieve.py @@ -43,6 +43,7 @@ def test_api_items_retrieve_anonymous_public_standalone(): }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 0, @@ -100,6 +101,7 @@ def test_api_items_retrieve_anonymous_public_parent(): }, "depth": 3, "is_favorite": False, + "is_restricted": False, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 0, @@ -204,6 +206,7 @@ def test_api_items_retrieve_authenticated_unrelated_public_or_authenticated(reac }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": reach, "link_role": item.link_role, "nb_accesses": 0, @@ -267,6 +270,7 @@ def test_api_items_retrieve_authenticated_public_or_authenticated_parent(reach): }, "depth": 3, "is_favorite": False, + "is_restricted": False, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 0, @@ -408,6 +412,7 @@ def test_api_items_retrieve_authenticated_related_direct(): "created_at": item.created_at.isoformat().replace("+00:00", "Z"), "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 2, @@ -475,6 +480,7 @@ def test_api_items_retrieve_authenticated_related_parent(): "created_at": item.created_at.isoformat().replace("+00:00", "Z"), "depth": 3, "is_favorite": False, + "is_restricted": False, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 2, @@ -652,6 +658,7 @@ def test_api_items_retrieve_authenticated_related_team_members(teams, role, mock }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 5, @@ -727,6 +734,7 @@ def test_api_items_retrieve_authenticated_related_team_administrators(teams, rol }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 5, @@ -802,6 +810,7 @@ def test_api_items_retrieve_authenticated_related_team_owners(teams, mock_user_t }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 5, @@ -1201,6 +1210,7 @@ def test_api_items_retrieve_file_with_url_property(upload_state): }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 1, @@ -1274,6 +1284,7 @@ def test_api_items_retrieve_file_with_url_property_non_previewable(upload_state) }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 1, @@ -1337,6 +1348,7 @@ def test_api_items_retrieve_file_with_url_property_with_spaces(): }, "depth": 1, "is_favorite": False, + "is_restricted": False, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 1, @@ -1482,6 +1494,7 @@ def test_api_items_retrieve_file_analysing_not_creator(): }, "depth": 1, "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": "public", "link_role": item.link_role, diff --git a/src/backend/core/tests/items/test_api_items_search.py b/src/backend/core/tests/items/test_api_items_search.py index 3595067d..593dc5f1 100644 --- a/src/backend/core/tests/items/test_api_items_search.py +++ b/src/backend/core/tests/items/test_api_items_search.py @@ -72,6 +72,7 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "id": str(top_parent.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": top_parent.link_reach, "link_role": top_parent.link_role, @@ -111,6 +112,7 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "id": str(parent.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": parent.link_reach, "link_role": parent.link_role, @@ -139,6 +141,7 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "id": str(top_parent.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": top_parent.link_reach, "link_role": top_parent.link_role, @@ -189,6 +192,7 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "id": str(children.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": children.link_reach, "link_role": children.link_role, @@ -217,6 +221,7 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "id": str(top_parent.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": top_parent.link_reach, "link_role": top_parent.link_role, @@ -255,6 +260,7 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "id": str(parent.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": parent.link_reach, "link_role": parent.link_role, diff --git a/src/backend/core/tests/items/test_api_items_search_fulltext.py b/src/backend/core/tests/items/test_api_items_search_fulltext.py index 4c2b9732..42d4185b 100644 --- a/src/backend/core/tests/items/test_api_items_search_fulltext.py +++ b/src/backend/core/tests/items/test_api_items_search_fulltext.py @@ -82,6 +82,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "hard_delete_at": None, "id": str(item_b.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": item_b.link_reach, "link_role": item_b.link_role, @@ -119,6 +120,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "filename": None, "hard_delete_at": None, "id": str(folder.id), + "is_restricted": False, "is_wopi_supported": False, "link_reach": folder.link_reach, "link_role": folder.link_role, @@ -159,6 +161,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "hard_delete_at": None, "id": str(item_c.id), "is_favorite": False, + "is_restricted": False, "is_wopi_supported": False, "link_reach": item_c.link_reach, "link_role": item_c.link_role, @@ -196,6 +199,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "filename": None, "hard_delete_at": None, "id": str(folder.id), + "is_restricted": False, "is_wopi_supported": False, "link_reach": folder.link_reach, "link_role": folder.link_role, diff --git a/src/backend/core/tests/items/test_api_items_trashbin.py b/src/backend/core/tests/items/test_api_items_trashbin.py index 40d83c2d..cf447800 100644 --- a/src/backend/core/tests/items/test_api_items_trashbin.py +++ b/src/backend/core/tests/items/test_api_items_trashbin.py @@ -103,6 +103,7 @@ def test_api_items_trashbin_format(settings): "size": None, "description": None, "hard_delete_at": ((now + timedelta(days=30)).isoformat()), + "is_restricted": False, "is_wopi_supported": False, } diff --git a/src/backend/core/tests/items/test_api_items_tree.py b/src/backend/core/tests/items/test_api_items_tree.py index 4c3ffa88..f2eb0472 100644 --- a/src/backend/core/tests/items/test_api_items_tree.py +++ b/src/backend/core/tests/items/test_api_items_tree.py @@ -158,6 +158,7 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "depth": 3, "id": str(level2_1.id), "is_favorite": False, + "is_restricted": False, "link_reach": level2_1.link_reach, "link_role": level2_1.link_role, "nb_accesses": 0, @@ -197,6 +198,7 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "depth": 3, "id": str(level2_2.id), "is_favorite": False, + "is_restricted": False, "link_reach": level2_2.link_reach, "link_role": level2_2.link_role, "nb_accesses": 0, @@ -230,6 +232,7 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "depth": 2, "id": str(level1_2.id), "is_favorite": False, + "is_restricted": False, "link_reach": level1_2.link_reach, "link_role": level1_2.link_role, "nb_accesses": 0, @@ -389,6 +392,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 3, "id": str(level2_1.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": level2_1.item.link_reach, "link_role": level2_1.item.link_role, "nb_accesses": 3, @@ -437,6 +441,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 4, "id": str(level3_1.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": level3_1.item.link_reach, "link_role": level3_1.item.link_role, "nb_accesses": 4, @@ -475,6 +480,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 3, "id": str(level2_2.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": level2_2.item.link_reach, "link_role": level2_2.item.link_role, "nb_accesses": 3, @@ -508,6 +514,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 2, "id": str(level1_1.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": level1_1.item.link_reach, "link_role": level1_1.item.link_role, "nb_accesses": 2, @@ -547,6 +554,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 2, "id": str(level1_2.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": level1_2.item.link_reach, "link_role": level1_2.item.link_role, "nb_accesses": 2, @@ -586,6 +594,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 2, "id": str(level1_3.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": level1_3.item.link_reach, "link_role": level1_3.item.link_role, "nb_accesses": 2, @@ -619,6 +628,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "depth": 1, "id": str(root.item.id), "is_favorite": False, + "is_restricted": False, "link_reach": root.item.link_reach, "link_role": root.item.link_role, "nb_accesses": 1, @@ -735,6 +745,7 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "depth": 2, "id": str(level1_1.id), "is_favorite": False, + "is_restricted": False, "link_reach": "authenticated", "link_role": level1_1.link_role, "nb_accesses": 0, @@ -779,6 +790,7 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "depth": 3, "id": str(level2_1.id), "is_favorite": False, + "is_restricted": False, "link_reach": "authenticated", "link_role": level2_1.link_role, "nb_accesses": 0, @@ -818,6 +830,7 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "depth": 3, "id": str(level2_2.id), "is_favorite": False, + "is_restricted": False, "link_reach": "authenticated", "link_role": level2_2.link_role, "nb_accesses": 0,