From e9db9d8eb01fba900884eccc86c41492eb27e73e Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 30 Jun 2026 16:58:02 +0200 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=94=A7(dev)=20align=20demo=20passwo?= =?UTF-8?q?rds=20with=20keycloak=20realm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow login with demo users in local development. --- docker/auth/realm.json | 56 +++++++++++++++++++ .../demo/management/commands/create_demo.py | 3 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 776c15827..a4f5ed8ae 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -64,6 +64,62 @@ ], "realmRoles": ["user"] }, + { + "username": "paige", + "email": "page.turner@library.book", + "firstName": "Paige", + "lastName": "Turner", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "pass" + } + ], + "realmRoles": ["user"] + }, + { + "username": "miles", + "email": "miles.ahead@roadmap.fwd", + "firstName": "Miles", + "lastName": "Ahead", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "pass" + } + ], + "realmRoles": ["user"] + }, + { + "username": "archie", + "email": "archie.vist@vaulted.docs", + "firstName": "Archie", + "lastName": "Vist", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "pass" + } + ], + "realmRoles": ["user"] + }, + { + "username": "wade", + "email": "wade.wilson@maximum.effort", + "firstName": "Wade", + "lastName": "Wilson", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "pass" + } + ], + "realmRoles": ["user"] + }, { "username": "user-e2e-chromium", "email": "user@chromium.test", diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py index 82c53f557..751cf133a 100644 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -6,6 +6,7 @@ from io import BytesIO from django.conf import settings +from django.contrib.auth.hashers import make_password from django.core.files.storage import default_storage from django.core.management.base import BaseCommand, CommandError @@ -80,7 +81,7 @@ def get_or_create_demo_user(user_data): "email": email, "full_name": user_data["full_name"], "short_name": user_data["short_name"], - "password": "!", # NOSONAR + "password": make_password("pass"), # NOSONAR "is_superuser": False, "is_active": True, "is_staff": False, From 6c6257200f0e290bd03e9b47927b2147a65ad58a Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 14:28:05 +0200 Subject: [PATCH 02/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20extract=20r?= =?UTF-8?q?ole=20resolution=20into=20a=20permissions=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move role and access resolution behind a backend resolved from the new PERMISSIONS_BACKEND setting, following the entitlements backend convention. Behavior is unchanged; this gives a single decision point to swap for an ABAC engine later. --- CHANGELOG.md | 1 + src/backend/core/models.py | 10 ++---- src/backend/core/permissions/__init__.py | 5 +++ .../core/permissions/backends/__init__.py | 1 + src/backend/core/permissions/backends/base.py | 34 +++++++++++++++++++ src/backend/core/permissions/backends/role.py | 27 +++++++++++++++ src/backend/core/permissions/factory.py | 12 +++++++ src/backend/drive/settings.py | 13 +++++++ 8 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 src/backend/core/permissions/__init__.py create mode 100644 src/backend/core/permissions/backends/__init__.py create mode 100644 src/backend/core/permissions/backends/base.py create mode 100644 src/backend/core/permissions/backends/role.py create mode 100644 src/backend/core/permissions/factory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd26814fb..c18f23234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to - ✨(frontend) add location, file type, contact and date search filters - ✨(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 ### Fixed diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 993f8502f..beb192709 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -42,6 +42,7 @@ from pydantic import BaseModel as PydanticBaseModel from timezone_field import TimeZoneField +from core.permissions import get_permissions_backend from core.storage.cache import invalidate_storage_used_cache from core.utils.item_title import manage_unique_title as manage_unique_title_utils from wopi.conversion.policy import target_extension_for @@ -1193,9 +1194,7 @@ def nb_accesses(self): nb_accesses = cache.get(cache_key) if nb_accesses is None: - nb_accesses = ItemAccess.objects.filter( - item__path__ancestors=self.path, - ).count() + nb_accesses = get_permissions_backend().effective_accesses(self).count() cache.set(cache_key, nb_accesses) return nb_accesses @@ -1235,10 +1234,7 @@ def get_role(self, user): try: roles = self.user_roles or [] except AttributeError: - roles = ItemAccess.objects.filter( - models.Q(user=user) | models.Q(team__in=user.teams), - item__path__ancestors=self.path, - ).values_list("role", flat=True) + roles = get_permissions_backend().roles_for(user, self) return RoleChoices.max(*roles) diff --git a/src/backend/core/permissions/__init__.py b/src/backend/core/permissions/__init__.py new file mode 100644 index 000000000..1703a9194 --- /dev/null +++ b/src/backend/core/permissions/__init__.py @@ -0,0 +1,5 @@ +"""Item permissions backend utilities.""" + +from core.permissions.factory import get_permissions_backend + +__all__ = ["get_permissions_backend"] diff --git a/src/backend/core/permissions/backends/__init__.py b/src/backend/core/permissions/backends/__init__.py new file mode 100644 index 000000000..868a68e7e --- /dev/null +++ b/src/backend/core/permissions/backends/__init__.py @@ -0,0 +1 @@ +"""Item permissions backends.""" diff --git a/src/backend/core/permissions/backends/base.py b/src/backend/core/permissions/backends/base.py new file mode 100644 index 000000000..0233b0d2f --- /dev/null +++ b/src/backend/core/permissions/backends/base.py @@ -0,0 +1,34 @@ +"""Permissions Backend base class.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from django.contrib.auth.models import AnonymousUser +from django.db.models import QuerySet + +from lasuite.drf.models.choices import RoleChoices + +if TYPE_CHECKING: + from core import models + + +class PermissionsBackend(ABC): + """Abstract base class for item permissions backends.""" + + @abstractmethod + def effective_accesses(self, item: models.Item) -> QuerySet[models.ItemAccess]: + """Return the accesses applying to the item, direct or inherited.""" + + @abstractmethod + def roles_at(self, user: models.User | AnonymousUser, path: str) -> QuerySet[str]: + """Return the roles the user holds at the given path, direct or inherited.""" + + @abstractmethod + def roles_for(self, user: models.User | AnonymousUser, item: models.Item) -> QuerySet[str]: + """Return the roles the user holds on the item, direct or inherited.""" + + def role_at(self, user: models.User | AnonymousUser, path: str) -> str | None: + """Return the highest role the user holds at the given path.""" + return RoleChoices.max(*self.roles_at(user, path)) diff --git a/src/backend/core/permissions/backends/role.py b/src/backend/core/permissions/backends/role.py new file mode 100644 index 000000000..b3e51bddc --- /dev/null +++ b/src/backend/core/permissions/backends/role.py @@ -0,0 +1,27 @@ +"""Role-based permissions backend.""" + +from django.db.models import Q + +from core import models +from core.permissions.backends.base import PermissionsBackend + + +class RolePermissionsBackend(PermissionsBackend): + """Role-based engine inheriting roles along the item tree.""" + + def effective_accesses(self, item): + """Return the accesses applying to the item, direct or inherited.""" + return models.ItemAccess.objects.filter( + item__path__ancestors=item.path, + ) + + def roles_at(self, user, path): + """Return the roles the user holds at the given path, direct or inherited.""" + return models.ItemAccess.objects.filter( + Q(user=user) | Q(team__in=user.teams), + item__path__ancestors=path, + ).values_list("role", flat=True) + + def roles_for(self, user, item): + """Return the roles the user holds on the item, direct or inherited.""" + return self.roles_at(user, item.path) diff --git a/src/backend/core/permissions/factory.py b/src/backend/core/permissions/factory.py new file mode 100644 index 000000000..14cc48b90 --- /dev/null +++ b/src/backend/core/permissions/factory.py @@ -0,0 +1,12 @@ +"""Permissions backend factory.""" + +import functools + +from django.conf import settings +from django.utils.module_loading import import_string + + +@functools.cache +def get_permissions_backend(): + """Get the permissions backend.""" + return import_string(settings.PERMISSIONS_BACKEND)(**settings.PERMISSIONS_BACKEND_PARAMETERS) diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index c12d1c45f..935d48106 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -135,6 +135,19 @@ class Base(Configuration): environ_prefix=None, ) + # Item permissions + PERMISSIONS_BACKEND = values.Value( + "core.permissions.backends.role.RolePermissionsBackend", + environ_name="PERMISSIONS_BACKEND", + environ_prefix=None, + ) + + PERMISSIONS_BACKEND_PARAMETERS = values.DictValue( + {}, + environ_name="PERMISSIONS_BACKEND_PARAMETERS", + environ_prefix=None, + ) + # Static files (CSS, JavaScript, Images) STATIC_URL = "/static/" STATIC_ROOT = os.path.join(DATA_DIR, "static") From 5c967a95275728cf229e1a22d35bee3f6a1aa7b8 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 14:35:20 +0200 Subject: [PATCH 03/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20move=20abil?= =?UTF-8?q?ities=20computation=20to=20the=20permissions=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move of Item.get_abilities into the role backend so every permission decision sits behind the same facade. --- src/backend/core/models.py | 90 +----------------- src/backend/core/permissions/backends/base.py | 4 + src/backend/core/permissions/backends/role.py | 91 +++++++++++++++++++ 3 files changed, 97 insertions(+), 88 deletions(-) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index beb192709..44ee963e1 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -45,7 +45,6 @@ from core.permissions import get_permissions_backend from core.storage.cache import invalidate_storage_used_cache from core.utils.item_title import manage_unique_title as manage_unique_title_utils -from wopi.conversion.policy import target_extension_for logger = getLogger(__name__) @@ -1314,93 +1313,8 @@ def computed_link_role(self): return self.computed_link_definition["link_role"] def get_abilities(self, user): - """ - Compute and return abilities for a given user on the item. - """ - # First get the role based on specific access - role = self.get_role(user) - # Characteristics that are based only on specific access - is_owner = role == RoleChoices.OWNER - is_deleted = self.ancestors_deleted_at - is_owner_or_admin = is_owner or role == RoleChoices.ADMIN - - # Compute access roles before adding link roles because we don't - # want anonymous users to access versions (we wouldn't know from - # which date to allow them anyway) - # Anonymous users should also not see item accesses - has_access_role = bool(role) and not is_deleted - link_select_options = ( - LinkReachChoices.get_select_options(**self.ancestors_link_definition) - if has_access_role - else {} - ) - - link_definition = self.computed_link_definition - - link_reach = link_definition["link_reach"] - if link_reach == LinkReachChoices.PUBLIC or ( - link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated - ): - # Set the user role to the highest role between the item role and the link role - # Needed for a user with an access lower than link_role - # Needed for a user without access to determine the role he has. - role = RoleChoices.max(role, link_definition["link_role"]) - can_get = bool(role) and not is_deleted - retrieve = can_get or is_owner - can_manage = is_owner_or_admin and not is_deleted - can_update = (is_owner_or_admin or role == RoleChoices.EDITOR) and not is_deleted - can_create_children = can_update and user.is_authenticated - can_hard_delete = ( - is_owner - if self.is_root - else (is_owner_or_admin or (user.is_authenticated and self.creator == user)) - ) - can_destroy = can_hard_delete and not is_deleted - can_duplicate = ( - can_get - and user.is_authenticated - and self.type == ItemTypeChoices.FILE - and self.upload_state == ItemUploadStateChoices.READY - ) - can_export = can_get and self.type == ItemTypeChoices.FOLDER - can_convert = ( - can_update - and self.type == ItemTypeChoices.FILE - and self.upload_state - in ( - ItemUploadStateChoices.READY, - ItemUploadStateChoices.ANALYZING, - ) - and bool(target_extension_for(self.extension)) - and bool(settings.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET) - ) - - return { - "accesses_manage": can_manage, - "accesses_view": has_access_role, - "breadcrumb": can_get, - "children_list": can_get, - "children_create": can_create_children, - "destroy": can_destroy, - "download": can_get, - "duplicate": can_duplicate, - "export": can_export, - "hard_delete": can_hard_delete, - "favorite": can_get and user.is_authenticated, - "link_configuration": can_manage, - "invite_owner": is_owner and not is_deleted, - "link_select_options": link_select_options, - "move": can_manage, - "restore": is_owner, - "retrieve": retrieve, - "tree": can_get, - "media_auth": can_get, - "partial_update": can_update, - "update": can_update, - "upload_ended": can_update and user.is_authenticated, - "wopi": can_get, - "convert": can_convert, - } + """Compute and return abilities for a given user on the item.""" + return get_permissions_backend().abilities(user, self) def send_email(self, subject, emails, context=None, language=None): """Generate and send email from a template.""" diff --git a/src/backend/core/permissions/backends/base.py b/src/backend/core/permissions/backends/base.py index 0233b0d2f..ba1438684 100644 --- a/src/backend/core/permissions/backends/base.py +++ b/src/backend/core/permissions/backends/base.py @@ -29,6 +29,10 @@ def roles_at(self, user: models.User | AnonymousUser, path: str) -> QuerySet[str def roles_for(self, user: models.User | AnonymousUser, item: models.Item) -> QuerySet[str]: """Return the roles the user holds on the item, direct or inherited.""" + @abstractmethod + def abilities(self, user: models.User | AnonymousUser, item: models.Item) -> dict: + """Compute and return abilities for a given user on the item.""" + def role_at(self, user: models.User | AnonymousUser, path: str) -> str | None: """Return the highest role the user holds at the given path.""" return RoleChoices.max(*self.roles_at(user, path)) diff --git a/src/backend/core/permissions/backends/role.py b/src/backend/core/permissions/backends/role.py index b3e51bddc..ffdbb387c 100644 --- a/src/backend/core/permissions/backends/role.py +++ b/src/backend/core/permissions/backends/role.py @@ -1,9 +1,13 @@ """Role-based permissions backend.""" +from django.conf import settings from django.db.models import Q +from lasuite.drf.models.choices import LinkReachChoices, RoleChoices + from core import models from core.permissions.backends.base import PermissionsBackend +from wopi.conversion.policy import target_extension_for class RolePermissionsBackend(PermissionsBackend): @@ -25,3 +29,90 @@ def roles_at(self, user, path): def roles_for(self, user, item): """Return the roles the user holds on the item, direct or inherited.""" return self.roles_at(user, item.path) + + def abilities(self, user, item): # pylint: disable=too-many-locals + """Compute and return abilities for a given user on the item.""" + # First get the role based on specific access + role = item.get_role(user) + # Characteristics that are based only on specific access + is_owner = role == RoleChoices.OWNER + is_deleted = item.ancestors_deleted_at + is_owner_or_admin = is_owner or role == RoleChoices.ADMIN + + # Compute access roles before adding link roles because we don't + # want anonymous users to access versions (we wouldn't know from + # which date to allow them anyway) + # Anonymous users should also not see item accesses + has_access_role = bool(role) and not is_deleted + link_select_options = ( + LinkReachChoices.get_select_options(**item.ancestors_link_definition) + if has_access_role + else {} + ) + + link_definition = item.computed_link_definition + + link_reach = link_definition["link_reach"] + if link_reach == LinkReachChoices.PUBLIC or ( + link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated + ): + # Set the user role to the highest role between the item role and the link role + # Needed for a user with an access lower than link_role + # Needed for a user without access to determine the role he has. + role = RoleChoices.max(role, link_definition["link_role"]) + can_get = bool(role) and not is_deleted + retrieve = can_get or is_owner + can_manage = is_owner_or_admin and not is_deleted + can_update = (is_owner_or_admin or role == RoleChoices.EDITOR) and not is_deleted + can_create_children = can_update and user.is_authenticated + can_hard_delete = ( + is_owner + if item.is_root + else (is_owner_or_admin or (user.is_authenticated and item.creator == user)) + ) + can_destroy = can_hard_delete and not is_deleted + can_duplicate = ( + can_get + and user.is_authenticated + and item.type == models.ItemTypeChoices.FILE + and item.upload_state == models.ItemUploadStateChoices.READY + ) + can_export = can_get and item.type == models.ItemTypeChoices.FOLDER + can_convert = ( + can_update + and item.type == models.ItemTypeChoices.FILE + and item.upload_state + in ( + models.ItemUploadStateChoices.READY, + models.ItemUploadStateChoices.ANALYZING, + ) + and bool(target_extension_for(item.extension)) + and bool(settings.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET) + ) + + return { + "accesses_manage": can_manage, + "accesses_view": has_access_role, + "breadcrumb": can_get, + "children_list": can_get, + "children_create": can_create_children, + "destroy": can_destroy, + "download": can_get, + "duplicate": can_duplicate, + "export": can_export, + "hard_delete": can_hard_delete, + "favorite": can_get and user.is_authenticated, + "link_configuration": can_manage, + "invite_owner": is_owner and not is_deleted, + "link_select_options": link_select_options, + "move": can_manage, + "restore": is_owner, + "retrieve": retrieve, + "tree": can_get, + "media_auth": can_get, + "partial_update": can_update, + "update": can_update, + "upload_ended": can_update and user.is_authenticated, + "wopi": can_get, + "convert": can_convert, + } From b0f9e69db3a7bbb7b85d1e9dc58e9eca3d3d7ea4 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 17:46:29 +0200 Subject: [PATCH 04/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20split=20abi?= =?UTF-8?q?lities=20into=20one=20property=20per=20ability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each ability now reads as a named rule on ItemAbilities, fixing the Sonar S3776 complexity of the former monolithic function. Per-action properties also sketch the vocabulary a future ABAC engine will implement, one check per action. --- src/backend/core/permissions/backends/role.py | 274 ++++++++++++------ 1 file changed, 185 insertions(+), 89 deletions(-) diff --git a/src/backend/core/permissions/backends/role.py b/src/backend/core/permissions/backends/role.py index ffdbb387c..8f6e51a0e 100644 --- a/src/backend/core/permissions/backends/role.py +++ b/src/backend/core/permissions/backends/role.py @@ -1,7 +1,12 @@ """Role-based permissions backend.""" +from __future__ import annotations + +from functools import cached_property + from django.conf import settings -from django.db.models import Q +from django.contrib.auth.models import AnonymousUser +from django.db.models import Q, QuerySet from lasuite.drf.models.choices import LinkReachChoices, RoleChoices @@ -10,109 +15,200 @@ from wopi.conversion.policy import target_extension_for +class ItemAbilities: # pylint: disable=too-many-public-methods + """Compute the abilities of a user on an item, one property per ability.""" + + def __init__(self, user: models.User | AnonymousUser, item: models.Item) -> None: + self.user = user + self.item = item + + @cached_property + def access_role(self) -> str | None: + """Return the role held through accesses only, before any link boost.""" + return self.item.get_role(self.user) + + @cached_property + def role(self) -> str | None: + """Return the effective role, link definition included.""" + link_definition = self.item.computed_link_definition + link_reach = link_definition["link_reach"] + if link_reach == LinkReachChoices.PUBLIC or ( + link_reach == LinkReachChoices.AUTHENTICATED and self.user.is_authenticated + ): + # The highest of the access role and the link role, needed for a user + # with an access lower than the link role and for a user without access + return RoleChoices.max(self.access_role, link_definition["link_role"]) + return self.access_role + + @cached_property + def is_deleted(self) -> bool: + """Return whether the item or one of its ancestors is soft deleted.""" + return bool(self.item.ancestors_deleted_at) + + @cached_property + def is_owner(self) -> bool: + """Return whether the user holds an owner role through accesses.""" + return self.access_role == RoleChoices.OWNER + + @cached_property + def is_owner_or_admin(self) -> bool: + """Return whether the user holds an owner or administrator role through accesses.""" + return self.is_owner or self.access_role == RoleChoices.ADMIN + + @cached_property + def has_access_role(self) -> bool: + """Return whether the user holds a role through accesses on a live item.""" + # Based on accesses only so that anonymous users granted by a link + # cannot see item accesses or versions + return bool(self.access_role) and not self.is_deleted + + @cached_property + def link_select_options(self) -> dict[str, list[str]]: + """Return the link reach and role options selectable on the item.""" + if not self.has_access_role: + return {} + return LinkReachChoices.get_select_options(**self.item.ancestors_link_definition) + + @property + def can_get(self) -> bool: + """Return whether the user can read the item.""" + return bool(self.role) and not self.is_deleted + + @property + def can_retrieve(self) -> bool: + """Return whether the user can retrieve the item, even soft deleted.""" + return self.can_get or self.is_owner + + @property + def can_manage(self) -> bool: + """Return whether the user can manage the item and its accesses.""" + return self.is_owner_or_admin and not self.is_deleted + + @property + def can_update(self) -> bool: + """Return whether the user can modify the item.""" + return (self.is_owner_or_admin or self.role == RoleChoices.EDITOR) and not self.is_deleted + + @property + def can_create_children(self) -> bool: + """Return whether the user can create children in the item.""" + return self.can_update and self.user.is_authenticated + + @cached_property + def can_hard_delete(self) -> bool: + """Return whether the user can delete the item permanently.""" + if self.item.is_root: + return self.is_owner + creator_can_delete = self.user.is_authenticated and self.item.creator_id == self.user.id + return self.is_owner_or_admin or creator_can_delete + + @property + def can_destroy(self) -> bool: + """Return whether the user can remove the item.""" + return self.can_hard_delete and not self.is_deleted + + @property + def can_duplicate(self) -> bool: + """Return whether the user can duplicate the file.""" + return ( + self.can_get + and self.user.is_authenticated + and self.item.type == models.ItemTypeChoices.FILE + and self.item.upload_state == models.ItemUploadStateChoices.READY + ) + + @property + def can_export(self) -> bool: + """Return whether the user can export the folder as an archive.""" + return self.can_get and self.item.type == models.ItemTypeChoices.FOLDER + + @property + def can_convert(self) -> bool: + """Return whether the user can convert the file to another format.""" + return ( + self.can_update + and self.item.type == models.ItemTypeChoices.FILE + and self.item.upload_state + in ( + models.ItemUploadStateChoices.READY, + models.ItemUploadStateChoices.ANALYZING, + ) + and bool(target_extension_for(self.item.extension)) + and bool(settings.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET) + ) + + @property + def can_favorite(self) -> bool: + """Return whether the user can mark the item as favorite.""" + return self.can_get and self.user.is_authenticated + + @property + def can_invite_owner(self) -> bool: + """Return whether the user can invite another owner on the item.""" + return self.is_owner and not self.is_deleted + + @property + def can_restore(self) -> bool: + """Return whether the user can restore the item from the trash.""" + return self.is_owner + + @property + def can_upload_ended(self) -> bool: + """Return whether the user can mark an upload on the item as ended.""" + return self.can_update and self.user.is_authenticated + + def as_dict(self) -> dict[str, bool | dict[str, list[str]]]: + """Return the ability mapping exposed by the API.""" + return { + "accesses_manage": self.can_manage, + "accesses_view": self.has_access_role, + "breadcrumb": self.can_get, + "children_list": self.can_get, + "children_create": self.can_create_children, + "destroy": self.can_destroy, + "download": self.can_get, + "duplicate": self.can_duplicate, + "export": self.can_export, + "hard_delete": self.can_hard_delete, + "favorite": self.can_favorite, + "link_configuration": self.can_manage, + "invite_owner": self.can_invite_owner, + "link_select_options": self.link_select_options, + "move": self.can_manage, + "restore": self.can_restore, + "retrieve": self.can_retrieve, + "tree": self.can_get, + "media_auth": self.can_get, + "partial_update": self.can_update, + "update": self.can_update, + "upload_ended": self.can_upload_ended, + "wopi": self.can_get, + "convert": self.can_convert, + } + + class RolePermissionsBackend(PermissionsBackend): """Role-based engine inheriting roles along the item tree.""" - def effective_accesses(self, item): + def effective_accesses(self, item: models.Item) -> QuerySet[models.ItemAccess]: """Return the accesses applying to the item, direct or inherited.""" return models.ItemAccess.objects.filter( item__path__ancestors=item.path, ) - def roles_at(self, user, path): + def roles_at(self, user: models.User | AnonymousUser, path: str) -> QuerySet[str]: """Return the roles the user holds at the given path, direct or inherited.""" return models.ItemAccess.objects.filter( Q(user=user) | Q(team__in=user.teams), item__path__ancestors=path, ).values_list("role", flat=True) - def roles_for(self, user, item): + def roles_for(self, user: models.User | AnonymousUser, item: models.Item) -> QuerySet[str]: """Return the roles the user holds on the item, direct or inherited.""" return self.roles_at(user, item.path) - def abilities(self, user, item): # pylint: disable=too-many-locals + def abilities( + self, user: models.User | AnonymousUser, item: models.Item + ) -> dict[str, bool | dict[str, list[str]]]: """Compute and return abilities for a given user on the item.""" - # First get the role based on specific access - role = item.get_role(user) - # Characteristics that are based only on specific access - is_owner = role == RoleChoices.OWNER - is_deleted = item.ancestors_deleted_at - is_owner_or_admin = is_owner or role == RoleChoices.ADMIN - - # Compute access roles before adding link roles because we don't - # want anonymous users to access versions (we wouldn't know from - # which date to allow them anyway) - # Anonymous users should also not see item accesses - has_access_role = bool(role) and not is_deleted - link_select_options = ( - LinkReachChoices.get_select_options(**item.ancestors_link_definition) - if has_access_role - else {} - ) - - link_definition = item.computed_link_definition - - link_reach = link_definition["link_reach"] - if link_reach == LinkReachChoices.PUBLIC or ( - link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated - ): - # Set the user role to the highest role between the item role and the link role - # Needed for a user with an access lower than link_role - # Needed for a user without access to determine the role he has. - role = RoleChoices.max(role, link_definition["link_role"]) - can_get = bool(role) and not is_deleted - retrieve = can_get or is_owner - can_manage = is_owner_or_admin and not is_deleted - can_update = (is_owner_or_admin or role == RoleChoices.EDITOR) and not is_deleted - can_create_children = can_update and user.is_authenticated - can_hard_delete = ( - is_owner - if item.is_root - else (is_owner_or_admin or (user.is_authenticated and item.creator == user)) - ) - can_destroy = can_hard_delete and not is_deleted - can_duplicate = ( - can_get - and user.is_authenticated - and item.type == models.ItemTypeChoices.FILE - and item.upload_state == models.ItemUploadStateChoices.READY - ) - can_export = can_get and item.type == models.ItemTypeChoices.FOLDER - can_convert = ( - can_update - and item.type == models.ItemTypeChoices.FILE - and item.upload_state - in ( - models.ItemUploadStateChoices.READY, - models.ItemUploadStateChoices.ANALYZING, - ) - and bool(target_extension_for(item.extension)) - and bool(settings.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET) - ) - - return { - "accesses_manage": can_manage, - "accesses_view": has_access_role, - "breadcrumb": can_get, - "children_list": can_get, - "children_create": can_create_children, - "destroy": can_destroy, - "download": can_get, - "duplicate": can_duplicate, - "export": can_export, - "hard_delete": can_hard_delete, - "favorite": can_get and user.is_authenticated, - "link_configuration": can_manage, - "invite_owner": is_owner and not is_deleted, - "link_select_options": link_select_options, - "move": can_manage, - "restore": is_owner, - "retrieve": retrieve, - "tree": can_get, - "media_auth": can_get, - "partial_update": can_update, - "update": can_update, - "upload_ended": can_update and user.is_authenticated, - "wopi": can_get, - "convert": can_convert, - } + return ItemAbilities(user, item).as_dict() From 812e9513e6fa4fe71622434fdaf132c2accb08a3 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 16:38:52 +0200 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=9A=A8(backend)=20refactor=20link?= =?UTF-8?q?=20validate=20to=20a=20single=20return?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar S3516 flags validate() because every return yields the same attrs value. Merge the restricted early return into an if/else with a single exit and move the ancestors validation to a helper to keep cognitive complexity low; behavior is unchanged. --- src/backend/core/api/serializers.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index a01eb217b..0893493b2 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -2,6 +2,8 @@ # pylint: disable=no-name-in-module +from __future__ import annotations + import json import logging from datetime import timedelta @@ -748,15 +750,8 @@ class Meta: "link_reach", ] - def validate(self, attrs): - """Validate that link_role and link_reach are compatible using get_select_options.""" - link_reach = attrs.get("link_reach") - link_role = attrs.get("link_role") - - if not link_reach: - raise serializers.ValidationError({"link_reach": _("This field is required.")}) - - # Get available options based on ancestors' link definition + def _validate_against_ancestors(self, link_reach: str, link_role: str) -> None: + """Validate the link definition against the options allowed by ancestors.""" available_options = LinkReachChoices.get_select_options( **self.instance.ancestors_link_definition ) @@ -788,12 +783,22 @@ def validate(self, attrs): raise serializers.ValidationError( { "link_role": ( - f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. " - f"Allowed roles: {allowed_roles_str}" + f"Link role '{link_role}' is not allowed for link reach " + f"'{link_reach}'. Allowed roles: {allowed_roles_str}" ) } ) + def validate(self, attrs: dict) -> dict: + """Validate that link_role and link_reach are compatible using get_select_options.""" + link_reach = attrs.get("link_reach") + link_role = attrs.get("link_role") + + if not link_reach: + raise serializers.ValidationError({"link_reach": _("This field is required.")}) + + self._validate_against_ancestors(link_reach, link_role) + return attrs From d8c26edbb370ed8d176e8aa711fc4a18e3f82deb Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 16:38:53 +0200 Subject: [PATCH 06/22] =?UTF-8?q?=E2=9C=85(backend)=20tighten=20exception?= =?UTF-8?q?=20tests=20around=20raising=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The soft delete assertion sat inside the pytest.raises block and never ran, hiding a wrong expected message. Indexer error tests now keep a single raising invocation inside the block so the failure source is unambiguous. Flagged by Sonar on PR #756. --- src/backend/core/tests/test_models_items.py | 7 ++++++- src/backend/core/tests/test_services_search_indexers.py | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index b5fadc2b3..0103681d1 100644 --- a/src/backend/core/tests/test_models_items.py +++ b/src/backend/core/tests/test_models_items.py @@ -101,7 +101,12 @@ def test_models_items_soft_delete(depth): with pytest.raises(RuntimeError) as exc_info: items[-1].soft_delete() - assert str(exc_info) == "This item is already deleted or has deleted ancestors." + + # The item deleted at random is either the last item itself or one of its ancestors + assert str(exc_info.value) in ( + "This item is already deleted or has deleted ancestors.", + "Cannot delete this item because one or more ancestors are already deleted.", + ) assert deleted_item.deleted_at is not None assert deleted_item.ancestors_deleted_at == deleted_item.deleted_at diff --git a/src/backend/core/tests/test_services_search_indexers.py b/src/backend/core/tests/test_services_search_indexers.py index 295ef5f86..3063e20c7 100644 --- a/src/backend/core/tests/test_services_search_indexers.py +++ b/src/backend/core/tests/test_services_search_indexers.py @@ -479,8 +479,9 @@ def test_services_search_indexers_index_errors(indexer_settings): body=json_dumps({"message": "Authentication failed."}), ) + indexer = SearchIndexer() with pytest.raises(HTTPError): - SearchIndexer().index() + indexer.index() @patch.object(SearchIndexer, "push") @@ -871,8 +872,9 @@ def test_services_search_indexers_search_errors(indexer_settings): body=json_dumps({"message": "Authentication failed."}), ) + indexer = SearchIndexer() with pytest.raises(HTTPError): - SearchIndexer().search("alpha", token="mytoken") + indexer.search("alpha", token="mytoken") @patch("requests.post") From 3418fae7d2e9cf79b5e406e2f0f98639311adac8 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Wed, 1 Jul 2026 19:58:54 +0200 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=90=9B(backend)=20override=20parent?= =?UTF-8?q?()=20to=20resolve=20it=20by=20exact=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TreeModel.parent() uses .last() which follows Meta.ordering (created_at). After a move(), the grandparent created later is returned instead of the direct parent. The parent path is known statically, so look it up by equality instead of scanning ancestors. --- CHANGELOG.md | 1 + src/backend/core/models.py | 6 ++++++ src/backend/core/tests/test_models_items.py | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18f23234..f46be7083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to - 🐛(backend) exclude folders from file type search results - 🐛(frontend) keep uploaded items usable while malware analysis runs - 🐛(backend) stream export files from S3 without buffering +- 🐛(backend) resolve the direct parent by exact path after a move ## [v0.19.0] - 2026-06-09 diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 44ee963e1..471f4f4bb 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1217,6 +1217,12 @@ def get_root(self): """Return the root of the tree.""" return self.ancestors().filter(path__depth=1).first() + def parent(self): + """Return the direct parent, looked up by its exact path.""" + if len(self.path) > 1: + return self._meta.model.objects.filter(path=str(self.path[:-1])).first() + return None + def invalidate_nb_accesses_cache(self): """ Invalidate the cache for number of accesses, including on affected descendants. diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index 0103681d1..5760331f4 100644 --- a/src/backend/core/tests/test_models_items.py +++ b/src/backend/core/tests/test_models_items.py @@ -55,6 +55,18 @@ def test_models_items_path_for_children_contains_parent_path(): assert str(child.path) == f"{parent.id!s}.{child.id!s}" +def test_models_items_parent_resolves_by_path_after_move(): + """The parent should stay the direct one after the subtree was moved.""" + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + new_grandparent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + + parent.move(new_grandparent) + child.refresh_from_db() + + assert child.parent() == parent + + def test_models_items_title_max_length(): """The "title" field should be 100 characters maximum.""" factories.ItemFactory(title="a" * 255) From 2b6ac3f3cf2816bf5750e2ca566317d10a79edd7 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Fri, 24 Jul 2026 17:12:50 +0200 Subject: [PATCH 08/22] =?UTF-8?q?=E2=9C=A8(backend)=20add=20is=5Frestricte?= =?UTF-8?q?d=20field=20to=20Item=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow folders to be marked as restricted. A DB constraint prevents setting is_restricted on non-folder items. --- .../migrations/0027_item_add_is_restricted.py | 22 +++++++++++++++++++ src/backend/core/models.py | 7 +++++- .../tests/test_models_items_restricted.py | 22 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/migrations/0027_item_add_is_restricted.py create mode 100644 src/backend/core/tests/test_models_items_restricted.py diff --git a/src/backend/core/migrations/0027_item_add_is_restricted.py b/src/backend/core/migrations/0027_item_add_is_restricted.py new file mode 100644 index 000000000..61a5cd170 --- /dev/null +++ b/src/backend/core/migrations/0027_item_add_is_restricted.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.14 on 2026-07-24 15:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0026_item_item_creator_size_not_hdel_idx'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='is_restricted', + field=models.BooleanField(default=False), + ), + migrations.AddConstraint( + model_name='item', + constraint=models.CheckConstraint(condition=models.Q(('is_restricted', False), ('type', 'folder'), _connector='OR'), name='check_is_restricted_only_on_folders'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 471f4f4bb..27ea2fe1e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1019,6 +1019,7 @@ class Item(TreeModel, BaseModel): blank=True, ) mimetype = models.CharField(max_length=255, null=True, blank=True) + is_restricted = models.BooleanField(default=False) main_workspace = models.BooleanField(default=False) size = models.BigIntegerField(null=True, blank=True) description = models.TextField(null=True, blank=True) @@ -1045,7 +1046,11 @@ class Meta: | models.Q(deleted_at=models.F("ancestors_deleted_at")) ), name="check_deleted_at_matches_ancestors_deleted_at_when_set", - ) + ), + models.CheckConstraint( + condition=(models.Q(is_restricted=False) | models.Q(type=ItemTypeChoices.FOLDER)), + name="check_is_restricted_only_on_folders", + ), ] indexes = [ GistIndex(fields=["path"]), diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py new file mode 100644 index 000000000..d57d46758 --- /dev/null +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -0,0 +1,22 @@ +"""Tests for restricted folder model behavior.""" + +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_items_restricted_folder_can_be_restricted(): + """A folder can be restricted.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER, is_restricted=True) + folder.refresh_from_db() + assert folder.is_restricted is True + + +def test_models_items_restricted_file_cannot_be_restricted(): + """A file cannot be restricted.""" + with pytest.raises(ValidationError): + factories.ItemFactory(type=models.ItemTypeChoices.FILE, is_restricted=True) From f3249958b0d00a73ade66b4e444dbd58e048ea0f Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Fri, 24 Jul 2026 17:34:19 +0200 Subject: [PATCH 09/22] =?UTF-8?q?=E2=9C=A8(backend)=20add=20shortcut=20ite?= =?UTF-8?q?m=20type=20targeting=20another=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shortcut materializes the original location of a restricted folder moved to the tree root. The OneToOne target enforces a single shortcut per folder and a DB constraint ties the target to the shortcut type. --- src/backend/core/factories.py | 14 ++++- .../0028_item_add_shortcut_target.py | 28 +++++++++ src/backend/core/models.py | 15 +++++ .../tests/items/test_api_item_upload_ended.py | 5 +- .../items/test_api_items_children_create.py | 19 +++--- src/backend/core/tests/test_models_items.py | 5 +- .../core/tests/test_models_items_shortcuts.py | 58 +++++++++++++++++++ 7 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 src/backend/core/migrations/0028_item_add_shortcut_target.py create mode 100644 src/backend/core/tests/test_models_items_shortcuts.py diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index f9aaa3ca5..0ee61f4f7 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -68,7 +68,7 @@ class Meta: creator = factory.SubFactory(UserFactory) deleted_at = None link_reach = LinkReachChoices.RESTRICTED - type = factory.fuzzy.FuzzyChoice([t[0] for t in models.ItemTypeChoices.choices]) + type = factory.fuzzy.FuzzyChoice([models.ItemTypeChoices.FOLDER, models.ItemTypeChoices.FILE]) filename = factory.lazy_attribute( lambda o: fake.file_name() if o.type == models.ItemTypeChoices.FILE else None ) @@ -138,6 +138,18 @@ def upload_bytes(self, create, extracted, **kwargs): default_storage.save(self.file_key, BytesIO(content)) +class ShortcutFactory(ItemFactory): + """A factory to create shortcuts pointing to a restricted root folder.""" + + type = models.ItemTypeChoices.SHORTCUT + filename = None + target = factory.SubFactory( + ItemFactory, + type=models.ItemTypeChoices.FOLDER, + is_restricted=True, + ) + + class UserItemAccessFactory(factory.django.DjangoModelFactory): """Create fake item user accesses for testing.""" diff --git a/src/backend/core/migrations/0028_item_add_shortcut_target.py b/src/backend/core/migrations/0028_item_add_shortcut_target.py new file mode 100644 index 000000000..f0f7defcd --- /dev/null +++ b/src/backend/core/migrations/0028_item_add_shortcut_target.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.14 on 2026-07-24 15:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_item_add_is_restricted'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='target', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shortcut', to='core.item'), + ), + migrations.AlterField( + model_name='item', + name='type', + field=models.CharField(choices=[('folder', 'Folder'), ('file', 'File'), ('shortcut', 'Shortcut')], default='folder', max_length=30), + ), + migrations.AddConstraint( + model_name='item', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('type', 'shortcut'), ('target__isnull', False)), models.Q(models.Q(('type', 'shortcut'), _negated=True), ('target__isnull', True)), _connector='OR'), name='check_target_only_on_shortcuts'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 27ea2fe1e..921b46bca 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -71,6 +71,7 @@ class ItemTypeChoices(models.TextChoices): FOLDER = "folder", _("Folder") FILE = "file", _("File") + SHORTCUT = "shortcut", _("Shortcut") class ItemUploadStateChoices(models.TextChoices): @@ -1020,6 +1021,13 @@ class Item(TreeModel, BaseModel): ) mimetype = models.CharField(max_length=255, null=True, blank=True) is_restricted = models.BooleanField(default=False) + target = models.OneToOneField( + "self", + on_delete=models.CASCADE, + related_name="shortcut", + null=True, + blank=True, + ) main_workspace = models.BooleanField(default=False) size = models.BigIntegerField(null=True, blank=True) description = models.TextField(null=True, blank=True) @@ -1051,6 +1059,13 @@ class Meta: condition=(models.Q(is_restricted=False) | models.Q(type=ItemTypeChoices.FOLDER)), name="check_is_restricted_only_on_folders", ), + models.CheckConstraint( + condition=( + (models.Q(type=ItemTypeChoices.SHORTCUT) & models.Q(target__isnull=False)) + | (~models.Q(type=ItemTypeChoices.SHORTCUT) & models.Q(target__isnull=True)) + ), + name="check_target_only_on_shortcuts", + ), ] indexes = [ GistIndex(fields=["path"]), diff --git a/src/backend/core/tests/items/test_api_item_upload_ended.py b/src/backend/core/tests/items/test_api_item_upload_ended.py index 99a02c79f..16225f80c 100644 --- a/src/backend/core/tests/items/test_api_item_upload_ended.py +++ b/src/backend/core/tests/items/test_api_item_upload_ended.py @@ -50,7 +50,10 @@ def test_api_item_upload_ended_on_none_file_item(item_type): client = APIClient() client.force_login(user) - item = factories.ItemFactory(type=item_type) + if item_type == ItemTypeChoices.SHORTCUT: + item = factories.ShortcutFactory() + else: + item = factories.ItemFactory(type=item_type) factories.UserItemAccessFactory(item=item, user=user, role="owner") response = client.post(f"/api/v1.0/items/{item.id!s}/upload-ended/") diff --git a/src/backend/core/tests/items/test_api_items_children_create.py b/src/backend/core/tests/items/test_api_items_children_create.py index 2e860d756..1a72046d8 100644 --- a/src/backend/core/tests/items/test_api_items_children_create.py +++ b/src/backend/core/tests/items/test_api_items_children_create.py @@ -3,7 +3,7 @@ """ from concurrent.futures import ThreadPoolExecutor -from random import choice, randint +from random import randint from unittest import mock from urllib.parse import parse_qs, urlparse from uuid import uuid4 @@ -414,7 +414,10 @@ def test_api_items_children_create_force_id_existing(): } -def test_api_items_children_create_not_a_folder(): +@pytest.mark.parametrize( + "item_type", [type for type in ItemTypeChoices.values if type != ItemTypeChoices.FOLDER] +) +def test_api_items_children_create_not_a_folder(item_type): """ It should not be possible to create a nested item below an item of type other than folder. @@ -423,13 +426,11 @@ def test_api_items_children_create_not_a_folder(): client = APIClient() client.force_login(user) - access = factories.UserItemAccessFactory( - user=user, - role="editor", - item__type=choice( - [type for type in ItemTypeChoices.values if type != ItemTypeChoices.FOLDER] - ), - ) + if item_type == ItemTypeChoices.SHORTCUT: + item = factories.ShortcutFactory() + else: + item = factories.ItemFactory(type=item_type) + access = factories.UserItemAccessFactory(user=user, role="editor", item=item) response = client.post( f"/api/v1.0/items/{access.item.id!s}/children/", diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index 5760331f4..21f2b1483 100644 --- a/src/backend/core/tests/test_models_items.py +++ b/src/backend/core/tests/test_models_items.py @@ -1106,7 +1106,10 @@ def test_models_items_nb_accesses_cache_is_invalidated_on_access_removal( @pytest.mark.parametrize("item_type", models.ItemTypeChoices.values) def test_models_items_default_upload_state(item_type): """The default value for the upload_state field depends on the item type.""" - item = factories.ItemFactory(type=item_type) + if item_type == models.ItemTypeChoices.SHORTCUT: + item = factories.ShortcutFactory() + else: + item = factories.ItemFactory(type=item_type) assert item.upload_state == ( models.ItemUploadStateChoices.PENDING if item.type == models.ItemTypeChoices.FILE else None ) diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py new file mode 100644 index 000000000..bb8c1f4c5 --- /dev/null +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -0,0 +1,58 @@ +"""Tests for shortcut items.""" + +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_items_shortcuts_factory(): + """The shortcut factory should build a shortcut pointing to a restricted root.""" + shortcut = factories.ShortcutFactory() + + assert shortcut.type == models.ItemTypeChoices.SHORTCUT + assert shortcut.target.is_restricted is True + assert shortcut.target.shortcut == shortcut + + +def test_models_items_shortcuts_require_a_target(): + """A shortcut cannot be created without a target.""" + with pytest.raises(ValidationError): + factories.ItemFactory(type=models.ItemTypeChoices.SHORTCUT) + + +@pytest.mark.parametrize("item_type", [models.ItemTypeChoices.FOLDER, models.ItemTypeChoices.FILE]) +def test_models_items_shortcuts_target_forbidden_on_other_types(item_type): + """Only shortcuts can carry a target.""" + target = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER, is_restricted=True) + + with pytest.raises(ValidationError): + factories.ItemFactory(type=item_type, target=target) + + +def test_models_items_shortcuts_unique_per_target(): + """A restricted folder cannot be targeted by two shortcuts.""" + shortcut = factories.ShortcutFactory() + + with pytest.raises(ValidationError): + factories.ShortcutFactory(target=shortcut.target) + + +def test_models_items_shortcuts_deleted_with_their_target(): + """Deleting the target row cascades to its shortcut.""" + shortcut = factories.ShortcutFactory() + target = shortcut.target + + models.Item.objects.filter(pk=target.pk).delete() + + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + + +def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): + """The generic item factory should only draw folder and file types.""" + types = {factories.ItemFactory().type for _ in range(20)} + + assert types <= {models.ItemTypeChoices.FOLDER, models.ItemTypeChoices.FILE} From c09a08949600d3d54ac25bbbb90215572c14ea62 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 10:32:41 +0200 Subject: [PATCH 10/22] =?UTF-8?q?=E2=9C=A8(backend)=20add=20restrict=20abi?= =?UTF-8?q?lity=20with=20activation=20and=20deactivation=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only an explicit owner can toggle restriction on a folder. A folder needs a parent to host its shortcut on activation, while an already restricted folder lives at the tree root and must stay deactivatable. --- src/backend/core/permissions/backends/role.py | 13 ++++ src/backend/core/tests/test_models_items.py | 10 +++ .../tests/test_models_items_restricted.py | 73 +++++++++++++++++++ .../core/tests/test_models_items_root.py | 7 ++ 4 files changed, 103 insertions(+) diff --git a/src/backend/core/permissions/backends/role.py b/src/backend/core/permissions/backends/role.py index 8f6e51a0e..fbe5bf6f9 100644 --- a/src/backend/core/permissions/backends/role.py +++ b/src/backend/core/permissions/backends/role.py @@ -137,6 +137,18 @@ def can_convert(self) -> bool: and bool(settings.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET) ) + @property + def can_restrict(self) -> bool: + """Return whether the user can toggle restriction on the folder.""" + # A restricted folder lives at the tree root: deactivation must stay + # possible there, while activation requires a parent for the shortcut + return ( + self.is_owner + and not self.is_deleted + and self.item.type == models.ItemTypeChoices.FOLDER + and (self.item.is_restricted or self.item.depth > 1) + ) + @property def can_favorite(self) -> bool: """Return whether the user can mark the item as favorite.""" @@ -175,6 +187,7 @@ def as_dict(self) -> dict[str, bool | dict[str, list[str]]]: "invite_owner": self.can_invite_owner, "link_select_options": self.link_select_options, "move": self.can_manage, + "restrict": self.can_restrict, "restore": self.can_restore, "retrieve": self.can_retrieve, "tree": self.can_get, diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index 21f2b1483..c37788012 100644 --- a/src/backend/core/tests/test_models_items.py +++ b/src/backend/core/tests/test_models_items.py @@ -319,6 +319,7 @@ def test_models_items_get_abilities_forbidden( "link_configuration": False, "link_select_options": {}, "partial_update": False, + "restrict": False, "restore": False, "retrieve": False, "tree": False, @@ -369,6 +370,7 @@ def test_models_items_get_abilities_reader(is_authenticated, reach, django_asser "download": True, "move": False, "partial_update": False, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -474,6 +476,7 @@ def test_models_items_get_abilities_editor( # noqa: PLR0913 "download": True, "move": False, "partial_update": True, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -544,6 +547,7 @@ def test_models_items_not_root_get_abilities_owner( "download": True, "move": True, "partial_update": True, + "restrict": False, "restore": True, "retrieve": True, "tree": True, @@ -574,6 +578,7 @@ def test_models_items_not_root_get_abilities_owner( "download": False, "move": False, "partial_update": False, + "restrict": False, "restore": True, "retrieve": True, "tree": False, @@ -634,6 +639,7 @@ def test_models_items_not_root_get_abilities_administrator( "download": True, "move": True, "partial_update": True, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -704,6 +710,7 @@ def test_models_items_not_root_get_abilities_editor_user( "download": True, "move": False, "partial_update": True, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -757,6 +764,7 @@ def test_models_items_not_root_get_abilities_reader_user(django_assert_num_queri "download": True, "move": False, "partial_update": access_from_link, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -815,6 +823,7 @@ def test_models_items_get_abilities_hard_delete_non_root_by_non_creator( "media_auth": True, "move": True, "partial_update": True, + "restrict": False, "restore": True, "retrieve": True, "tree": True, @@ -846,6 +855,7 @@ def test_models_items_get_abilities_hard_delete_non_root_by_non_creator( "media_auth": False, "move": False, "partial_update": False, + "restrict": False, "restore": True, "retrieve": True, "tree": False, diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py index d57d46758..85535fd38 100644 --- a/src/backend/core/tests/test_models_items_restricted.py +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -9,6 +9,79 @@ pytestmark = pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected", + [ + ("owner", True), + ("administrator", False), + ("editor", False), + ("reader", False), + ], +) +def test_models_items_restricted_get_abilities_restrict_requires_owner(role, expected): + """Only an owner can restrict a folder.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + factories.UserItemAccessFactory(item=folder, user=user, role=role) + + abilities = folder.get_abilities(user) + + assert abilities["restrict"] is expected + + +def test_models_items_restricted_get_abilities_restrict_forbidden_on_roots(): + """A root folder cannot be restricted: no parent can hold its shortcut.""" + user = factories.UserFactory() + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, models.RoleChoices.OWNER)], + ) + + abilities = folder.get_abilities(user) + + assert abilities["restrict"] is False + + +def test_models_items_restricted_get_abilities_restrict_allowed_on_restricted_root(): + """An explicit owner can deactivate a restricted folder moved to the root.""" + user = factories.UserFactory() + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + is_restricted=True, + users=[(user, models.RoleChoices.OWNER)], + ) + + abilities = folder.get_abilities(user) + + assert abilities["restrict"] is True + + +def test_models_items_restricted_get_abilities_restrict_forbidden_on_files(): + """A file cannot be restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + item = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FILE) + factories.UserItemAccessFactory(item=item, user=user, role="owner") + + abilities = item.get_abilities(user) + + assert abilities["restrict"] is False + + +def test_models_items_restricted_get_abilities_restrict_forbidden_when_deleted(): + """A soft deleted folder cannot be restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + factories.UserItemAccessFactory(item=folder, user=user, role="owner") + folder.soft_delete() + + abilities = folder.get_abilities(user) + + assert abilities["restrict"] is False + + def test_models_items_restricted_folder_can_be_restricted(): """A folder can be restricted.""" folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER, is_restricted=True) diff --git a/src/backend/core/tests/test_models_items_root.py b/src/backend/core/tests/test_models_items_root.py index 90a103e3d..d6ab74c4e 100644 --- a/src/backend/core/tests/test_models_items_root.py +++ b/src/backend/core/tests/test_models_items_root.py @@ -65,6 +65,7 @@ def test_models_sub_item_abilities_downgraded(): "download": True, "move": False, "partial_update": True, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -101,6 +102,7 @@ def test_models_sub_item_abilities_downgraded(): "download": True, "move": False, "partial_update": False, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -155,6 +157,7 @@ def test_models_items_root_get_abilities_owner( "download": True, "move": True, "partial_update": True, + "restrict": False, "restore": True, "retrieve": True, "tree": True, @@ -185,6 +188,7 @@ def test_models_items_root_get_abilities_owner( "download": False, "move": False, "partial_update": False, + "restrict": False, "restore": True, "retrieve": True, "tree": False, @@ -242,6 +246,7 @@ def test_models_items_root_get_abilities_administrator( "download": True, "move": True, "partial_update": True, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -308,6 +313,7 @@ def test_models_items_root_get_abilities_editor_user( "download": True, "move": False, "partial_update": True, + "restrict": False, "restore": False, "retrieve": True, "tree": True, @@ -361,6 +367,7 @@ def test_models_items_root_get_abilities_reader_user( "download": True, "move": False, "partial_update": access_from_link, + "restrict": False, "restore": False, "retrieve": True, "tree": True, From 6788b6fa9a3cbaceda5791fcd7e7ea1e574ca6d5 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 11:01:22 +0200 Subject: [PATCH 11/22] =?UTF-8?q?=E2=9C=A8(backend)=20activate=20restricti?= =?UTF-8?q?on=20by=20moving=20the=20folder=20to=20the=20tree=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restriction is structural: the folder physically leaves its parent so inheritance stops applying without any query-level cut. A shortcut materializes its origin location. Explicit link reach is kept and defaults to restricted only when it was inherited. --- src/backend/core/models.py | 92 +++++++ .../tests/test_models_items_restricted.py | 224 ++++++++++++++++++ .../core/tests/test_models_items_shortcuts.py | 12 + 3 files changed, 328 insertions(+) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 921b46bca..f2655f253 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1541,6 +1541,30 @@ def move(self, target): } ) + if self.is_restricted: + raise ValidationError( + { + "target": ValidationError( + _("A restricted folder cannot be moved"), + code="item_move_restricted", + ) + } + ) + + if ( + self.type == ItemTypeChoices.SHORTCUT + and target + and str(target.path).startswith(str(self.target.path)) + ): + raise ValidationError( + { + "target": ValidationError( + _("A shortcut cannot be moved under its own target"), + code="item_move_shortcut_under_target", + ) + } + ) + old_path = self.path if target: self.path = f"{target.path!s}.{self.id!s}" @@ -1555,6 +1579,74 @@ def move(self, target): path=RawSQL("%s || subpath(path, nlevel(%s))", (str(self.path), str(old_path))) ) + @transaction.atomic + def restrict(self, user): + """Restrict the folder by detaching it to the tree root behind a shortcut.""" + item = self._meta.model.objects.select_for_update().get(pk=self.pk) + + if item.type != ItemTypeChoices.FOLDER: + raise ValidationError( + { + "is_restricted": ValidationError( + _("Only folders can be restricted"), + code="item_restrict_type_folder_only", + ) + } + ) + if item.is_restricted: + raise ValidationError( + { + "is_restricted": ValidationError( + _("This folder is already restricted"), + code="item_restrict_already_restricted", + ) + } + ) + if item.depth == 1: + raise ValidationError( + { + "is_restricted": ValidationError( + _("A root folder cannot be restricted"), + code="item_restrict_root", + ) + } + ) + if item.ancestors_deleted_at: + raise ValidationError( + { + "is_restricted": ValidationError( + _("A deleted folder cannot be restricted"), + code="item_restrict_deleted", + ) + } + ) + + parent = item.parent() + + ItemAccess.objects.update_or_create( + item=item, user=user, defaults={"role": RoleChoices.OWNER} + ) + + # The move must run while the flag is still unset: move() refuses + # restricted folders + item.move(None) + + item.is_restricted = True + if item.link_reach is None: + item.link_reach = LinkReachChoices.RESTRICTED + item.save(update_fields=["is_restricted", "link_reach"]) + + self._meta.model.objects.create_child( + parent=parent, + creator=user, + type=ItemTypeChoices.SHORTCUT, + target=item, + title=item.title, + ) + item.invalidate_nb_accesses_cache() + + return item + class MirrorItemTask(BaseModel): """Model managing a status for a mirroring task.""" diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py index 85535fd38..14bc0535e 100644 --- a/src/backend/core/tests/test_models_items_restricted.py +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError import pytest +from lasuite.drf.models.choices import LinkReachChoices from core import factories, models @@ -93,3 +94,226 @@ def test_models_items_restricted_file_cannot_be_restricted(): """A file cannot be restricted.""" with pytest.raises(ValidationError): factories.ItemFactory(type=models.ItemTypeChoices.FILE, is_restricted=True) + + +def test_models_items_restricted_restrict_moves_folder_to_root(): + """Activating restriction moves the folder and its subtree to the tree root.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + + folder.restrict(user) + + folder.refresh_from_db() + child.refresh_from_db() + assert str(folder.path) == str(folder.id) + assert str(child.path) == f"{folder.id!s}.{child.id!s}" + + +def test_models_items_restricted_restrict_creates_shortcut(): + """Activating restriction materializes the origin location with a shortcut.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + + folder.restrict(user) + + folder.refresh_from_db() + shortcut = folder.shortcut + assert shortcut.type == models.ItemTypeChoices.SHORTCUT + assert str(shortcut.path) == f"{parent.id!s}.{shortcut.id!s}" + assert shortcut.title == folder.title + assert shortcut.creator == user + + +def test_models_items_restricted_restrict_sets_flag_and_creates_owner_access(): + """Activating restriction sets is_restricted and creates an explicit owner access.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + + assert not models.ItemAccess.objects.filter(item=folder, user=user).exists() + + folder = folder.restrict(user) + + assert folder.is_restricted is True + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() + + +def test_models_items_restricted_restrict_keeps_existing_explicit_access(): + """Activating restriction does not duplicate an existing explicit owner access.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + factories.UserItemAccessFactory(item=folder, user=user, role="owner") + + folder.restrict(user) + + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").count() == 1 + + +def test_models_items_restricted_restrict_promotes_existing_lower_access(): + """Activating restriction promotes an existing lower explicit access to owner.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + access = factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + folder.restrict(user) + + access.refresh_from_db() + assert access.role == models.RoleChoices.OWNER + + +def test_models_items_restricted_restrict_defaults_link_reach(): + """Activating restriction sets link reach to restricted when none is explicit.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.PUBLIC, + link_role="reader", + ) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + + folder = folder.restrict(user) + + assert folder.link_reach == LinkReachChoices.RESTRICTED + + +def test_models_items_restricted_restrict_keeps_explicit_link_reach(): + """Activating restriction keeps an existing explicit link reach.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.AUTHENTICATED, + ) + + folder = folder.restrict(user) + + assert folder.link_reach == LinkReachChoices.AUTHENTICATED + + +def test_models_items_restricted_restrict_requires_a_folder(): + """Only folders can be restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + item = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FILE) + + with pytest.raises(ValidationError, match="Only folders can be restricted"): + item.restrict(user) + + +def test_models_items_restricted_restrict_rejects_already_restricted(): + """A restricted folder cannot be restricted again.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder.restrict(user) + folder.refresh_from_db() + + with pytest.raises(ValidationError, match="This folder is already restricted"): + folder.restrict(user) + + +def test_models_items_restricted_restrict_rejects_roots(): + """A root folder cannot be restricted.""" + user = factories.UserFactory() + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + + with pytest.raises(ValidationError, match="A root folder cannot be restricted"): + folder.restrict(user) + + +def test_models_items_restricted_restrict_rejects_deleted(): + """A deleted folder cannot be restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + parent.soft_delete() + folder.refresh_from_db() + + with pytest.raises(ValidationError, match="A deleted folder cannot be restricted"): + folder.restrict(user) + + +def test_models_items_restricted_cuts_role_inheritance(): + """Roles inherited from former ancestors stop applying once restricted.""" + parent_user = factories.UserFactory() + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_user, models.RoleChoices.OWNER)], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + + folder = folder.restrict(user) + + assert folder.get_role(parent_user) is None + assert folder.get_role(user) == models.RoleChoices.OWNER + + +def test_models_items_restricted_descendants_inherit_from_restricted_folder(): + """Descendants inherit the explicit accesses of the restricted folder only.""" + parent_user = factories.UserFactory() + user = factories.UserFactory() + reader = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_user, models.RoleChoices.OWNER)], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + factories.UserItemAccessFactory(item=folder, user=reader, role="reader") + + folder.restrict(user) + + child.refresh_from_db() + assert child.get_role(reader) == models.RoleChoices.READER + assert child.get_role(parent_user) is None + + +def test_models_items_restricted_cuts_link_inheritance(): + """The link definition of former ancestors stops applying once restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.PUBLIC, + link_role="editor", + ) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + + folder = folder.restrict(user) + + child.refresh_from_db() + assert folder.computed_link_definition == { + "link_reach": LinkReachChoices.RESTRICTED, + "link_role": None, + } + assert child.computed_link_definition == { + "link_reach": LinkReachChoices.RESTRICTED, + "link_role": None, + } + + +def test_models_items_restricted_move_rejects_restricted_roots(): + """A restricted folder cannot be moved: it must be deactivated first.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + other = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + with pytest.raises(ValidationError, match="A restricted folder cannot be moved"): + folder.move(other) diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index bb8c1f4c5..6580f2047 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -51,6 +51,18 @@ def test_models_items_shortcuts_deleted_with_their_target(): assert not models.Item.objects.filter(pk=shortcut.pk).exists() +def test_models_items_shortcuts_move_rejects_own_target_subtree(): + """A shortcut cannot be moved under the subtree of its own target.""" + shortcut = factories.ShortcutFactory() + folder = factories.ItemFactory( + parent=shortcut.target, + type=models.ItemTypeChoices.FOLDER, + ) + + with pytest.raises(ValidationError, match="cannot be moved under its own target"): + shortcut.move(folder) + + def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): """The generic item factory should only draw folder and file types.""" types = {factories.ItemFactory().type for _ in range(20)} From 26c50735624b731a184748c219cf345d715165f5 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 11:53:11 +0200 Subject: [PATCH 12/22] =?UTF-8?q?=E2=9C=A8(backend)=20deactivate=20restric?= =?UTF-8?q?tion=20by=20reattaching=20at=20the=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder returns under its shortcut's current parent and the shortcut disappears. Without a live shortcut the folder stays a detached root. Inheritance applies again through the tree structure. --- src/backend/core/models.py | 40 +++++++++- .../tests/test_models_items_restricted.py | 76 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f2655f253..fff616d71 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -982,7 +982,7 @@ def create_child(self, parent=None, **kwargs): return item -# pylint: disable=too-many-public-methods +# pylint: disable=too-many-public-methods,too-many-instance-attributes class Item(TreeModel, BaseModel): """Item in the tree.""" @@ -1647,6 +1647,44 @@ def restrict(self, user): return item + @transaction.atomic + def unrestrict(self): + """Lift restriction and reattach the folder at its shortcut location.""" + self._meta.model.objects.select_for_update().get(pk=self.pk) + self.refresh_from_db() + + if not self.is_restricted: + raise ValidationError( + { + "is_restricted": ValidationError( + _("This folder is not restricted"), + code="item_unrestrict_not_restricted", + ) + } + ) + + shortcut = self._meta.model.objects.select_for_update().filter(target=self).first() + parent = None + if shortcut: + if shortcut.ancestors_deleted_at is None: + parent = shortcut.parent() + self._meta.model.objects.filter(pk=shortcut.pk).delete() + + # The flag must fall before the move: move() refuses restricted folders + self.is_restricted = False + self.save(update_fields=["is_restricted"]) + + if parent: + self.title = manage_unique_title_utils( + self._meta.model.objects.children(parent.path), self.title + ) + self.save(update_fields=["title"]) + self.move(parent) + + self.invalidate_nb_accesses_cache() + + return self + class MirrorItemTask(BaseModel): """Model managing a status for a mirroring task.""" diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py index 14bc0535e..3ec0adcbb 100644 --- a/src/backend/core/tests/test_models_items_restricted.py +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -307,6 +307,82 @@ def test_models_items_restricted_cuts_link_inheritance(): } +def test_models_items_restricted_unrestrict_moves_folder_back(): + """Deactivating restriction reattaches the subtree at the shortcut location.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FILE) + folder = folder.restrict(user) + + folder = folder.unrestrict() + + child.refresh_from_db() + assert folder.is_restricted is False + assert str(folder.path) == f"{parent.id!s}.{folder.id!s}" + assert str(child.path) == f"{parent.id!s}.{folder.id!s}.{child.id!s}" + assert not models.Item.objects.filter(target=folder).exists() + + +def test_models_items_restricted_unrestrict_follows_moved_shortcut(): + """Deactivating restriction reattaches the folder at the shortcut's current parent.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + other = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + folder.shortcut.move(other) + + folder = folder.unrestrict() + + assert str(folder.path) == f"{other.id!s}.{folder.id!s}" + + +def test_models_items_restricted_unrestrict_without_live_shortcut(): + """Deactivating restriction leaves the folder at the root when its shortcut is trashed.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + parent.soft_delete() + + folder = folder.unrestrict() + + assert folder.is_restricted is False + assert str(folder.path) == str(folder.id) + assert not models.Item.objects.filter(target=folder).exists() + + +def test_models_items_restricted_deactivate_restriction_deduplicates_title(): + """Deactivating restriction renames the folder when its title was reused.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + shortcut = folder.shortcut + shortcut.title = "renamed shortcut" + shortcut.save() + models.Item.objects.create_child( + parent=parent, + creator=user, + type=models.ItemTypeChoices.FOLDER, + title=folder.title, + ) + + folder = folder.unrestrict() + + assert folder.title.endswith("_01") + assert str(folder.path) == f"{parent.id!s}.{folder.id!s}" + + +def test_models_items_restricted_unrestrict_requires_restricted(): + """Only a restricted folder can be deactivated.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + + with pytest.raises(ValidationError, match="This folder is not restricted"): + folder.unrestrict() + + def test_models_items_restricted_move_rejects_restricted_roots(): """A restricted folder cannot be moved: it must be deactivated first.""" user = factories.UserFactory() From f91593efe2fcfc7d2fba8abd557013f2e6f102f7 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 12:30:32 +0200 Subject: [PATCH 13/22] =?UTF-8?q?=E2=9C=A8(backend)=20normalize=20explicit?= =?UTF-8?q?=20accesses=20on=20restriction=20deactivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit roles granted during restriction that are now covered by inheritance are dropped, so the sharing screen does not keep dead entries. Superior roles and roles without inherited counterpart stay. --- src/backend/core/models.py | 22 ++++++ .../tests/test_models_items_restricted.py | 76 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index fff616d71..208726fc7 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1647,6 +1647,27 @@ def restrict(self, user): return item + def _normalize_explicit_accesses(self): + """Delete explicit accesses inferior or equal to the inherited role.""" + inherited_accesses = ( + ItemAccess.objects.filter(item__path__ancestors=self.path) + .exclude(item=self) + .values_list("user_id", "team", "role") + ) + inherited_roles = {} + for user_id, team, role in inherited_accesses: + key = (user_id, team) + inherited_roles[key] = RoleChoices.max(inherited_roles.get(key), role) + + redundant_ids = [ + access.id + for access in ItemAccess.objects.filter(item=self) + if RoleChoices.get_priority(access.role) + <= RoleChoices.get_priority(inherited_roles.get((access.user_id, access.team))) + ] + if redundant_ids: + ItemAccess.objects.filter(id__in=redundant_ids).delete() + @transaction.atomic def unrestrict(self): """Lift restriction and reattach the folder at its shortcut location.""" @@ -1680,6 +1701,7 @@ def unrestrict(self): ) self.save(update_fields=["title"]) self.move(parent) + self._normalize_explicit_accesses() self.invalidate_nb_accesses_cache() diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py index 3ec0adcbb..8e9f4e854 100644 --- a/src/backend/core/tests/test_models_items_restricted.py +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -353,6 +353,82 @@ def test_models_items_restricted_unrestrict_without_live_shortcut(): assert not models.Item.objects.filter(target=folder).exists() +def test_models_items_restricted_unrestrict_removes_redundant_access(): + """Deactivating restriction removes explicit accesses inferior or equal to inherited.""" + parent_owner = factories.UserFactory() + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, models.RoleChoices.OWNER)], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + factories.UserItemAccessFactory(item=folder, user=parent_owner, role="editor") + + folder = folder.unrestrict() + + assert not models.ItemAccess.objects.filter(item=folder, user=parent_owner).exists() + + +def test_models_items_restricted_unrestrict_keeps_superior_access(): + """Deactivating restriction keeps explicit accesses superior to inherited.""" + reader = factories.UserFactory() + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(reader, models.RoleChoices.READER)], + ) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + factories.UserItemAccessFactory(item=folder, user=reader, role="editor") + + folder = folder.unrestrict() + + assert models.ItemAccess.objects.filter(item=folder, user=reader, role="editor").exists() + + +def test_models_items_restricted_unrestrict_removes_redundant_team_access(): + """Deactivating restriction removes explicit team accesses inferior or equal to inherited.""" + team = "test-team" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + factories.TeamItemAccessFactory(item=parent, team=team, role="owner") + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + factories.TeamItemAccessFactory(item=folder, team=team, role="editor") + + folder = folder.unrestrict() + + assert not models.ItemAccess.objects.filter(item=folder, team=team).exists() + + +def test_models_items_restricted_unrestrict_keeps_superior_team_access(): + """Deactivating restriction keeps explicit team accesses superior to inherited.""" + team = "test-team" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + factories.TeamItemAccessFactory(item=parent, team=team, role="reader") + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + factories.TeamItemAccessFactory(item=folder, team=team, role="editor") + + folder = folder.unrestrict() + + assert models.ItemAccess.objects.filter(item=folder, team=team, role="editor").exists() + + +def test_models_items_restricted_unrestrict_keeps_access_without_inheritance(): + """Deactivating restriction keeps explicit accesses when there is no inherited role.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + folder = folder.unrestrict() + + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() + + def test_models_items_restricted_deactivate_restriction_deduplicates_title(): """Deactivating restriction renames the folder when its title was reused.""" user = factories.UserFactory() From bd2c132f03d3f986007cad72fa19b9b3f7ef2faf Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 13:17:25 +0200 Subject: [PATCH 14/22] =?UTF-8?q?=E2=9C=A8(backend)=20normalize=20explicit?= =?UTF-8?q?=20link=20reach=20on=20restriction=20deactivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reach kept from the restriction period is reset to inherit when the reattached parent already grants as much or more. A more open explicit reach survives, matching the role normalization rule. --- src/backend/core/models.py | 12 ++++++ .../tests/test_models_items_restricted.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 208726fc7..d05e41119 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1668,6 +1668,17 @@ def _normalize_explicit_accesses(self): if redundant_ids: ItemAccess.objects.filter(id__in=redundant_ids).delete() + def _normalize_explicit_link_reach(self): + """Reset the link reach to inherit when inferior or equal to the inherited one.""" + # The cached ancestors definition predates the move, recompute it + self._ancestors_link_definition = None + inherited_reach = self.ancestors_link_definition["link_reach"] + if LinkReachChoices.get_priority(self.link_reach) <= LinkReachChoices.get_priority( + inherited_reach + ): + self.link_reach = None + self.save(update_fields=["link_reach"]) + @transaction.atomic def unrestrict(self): """Lift restriction and reattach the folder at its shortcut location.""" @@ -1702,6 +1713,7 @@ def unrestrict(self): self.save(update_fields=["title"]) self.move(parent) self._normalize_explicit_accesses() + self._normalize_explicit_link_reach() self.invalidate_nb_accesses_cache() diff --git a/src/backend/core/tests/test_models_items_restricted.py b/src/backend/core/tests/test_models_items_restricted.py index 8e9f4e854..a43ac647d 100644 --- a/src/backend/core/tests/test_models_items_restricted.py +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -429,7 +429,48 @@ def test_models_items_restricted_unrestrict_keeps_access_without_inheritance(): assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() -def test_models_items_restricted_deactivate_restriction_deduplicates_title(): +def test_models_items_restricted_unrestrict_resets_redundant_link_reach(): + """Deactivating restriction resets a link reach inferior or equal to inherited.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.PUBLIC, + link_role="reader", + ) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + folder = folder.restrict(user) + assert folder.link_reach == LinkReachChoices.RESTRICTED + + folder = folder.unrestrict() + + assert folder.link_reach is None + + +def test_models_items_restricted_unrestrict_keeps_superior_link_reach(): + """Deactivating restriction keeps a link reach superior to inherited.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.AUTHENTICATED, + link_role="reader", + ) + folder = factories.ItemFactory( + parent=parent, + type=models.ItemTypeChoices.FOLDER, + link_reach=LinkReachChoices.PUBLIC, + ) + folder = folder.restrict(user) + + folder = folder.unrestrict() + + assert folder.link_reach == LinkReachChoices.PUBLIC + + +def test_models_items_restricted_unrestrict_deduplicates_title(): """Deactivating restriction renames the folder when its title was reused.""" user = factories.UserFactory() parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) From eb5ee3b3e8cf9dbe52ba29d636c66d0f7be98bcb Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 14:44:52 +0200 Subject: [PATCH 15/22] =?UTF-8?q?=E2=9C=A8(backend)=20expose=20is=5Frestri?= =?UTF-8?q?cted=20field=20in=20items=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners activate and deactivate restriction on folders via PATCH, gated by the restrict ability. The serializer keeps working on the instance returned by the toggle since the item physically moves. --- CHANGELOG.md | 1 + src/backend/core/api/serializers.py | 19 ++- .../items/test_api_items_children_list.py | 18 +++ .../core/tests/items/test_api_items_list.py | 3 + .../tests/items/test_api_items_restrict.py | 148 ++++++++++++++++++ .../tests/items/test_api_items_retrieve.py | 13 ++ .../core/tests/items/test_api_items_search.py | 6 + .../items/test_api_items_search_fulltext.py | 4 + .../tests/items/test_api_items_trashbin.py | 1 + .../core/tests/items/test_api_items_tree.py | 13 ++ 10 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 src/backend/core/tests/items/test_api_items_restrict.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f46be7083..23f811be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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 0893493b2..115edcc3a 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -15,7 +15,7 @@ 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 Meta: "is_favorite", "link_role", "link_reach", + "is_restricted", "nb_accesses", "numchild", "numchild_folder", @@ -282,6 +283,7 @@ class Meta: "creator", "depth", "is_favorite", + "is_restricted", "link_role", "link_reach", "nb_accesses", @@ -480,6 +482,7 @@ class Meta: "is_favorite", "link_role", "link_reach", + "is_restricted", "nb_accesses", "numchild", "numchild_folder", @@ -536,7 +539,19 @@ def create(self, validated_data): 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 5321d138e..58476e048 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 b3e01478b..b4898f171 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 000000000..5010eb526 --- /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 30b373185..7151987a2 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 3595067dc..593dc5f14 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 4c2b9732b..42d4185be 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 40d83c2df..cf447800d 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 4c3ffa88d..f2eb0472a 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, From be57b681a42f521fda441daa4e414b3c57d495db Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 16:31:41 +0200 Subject: [PATCH 16/22] =?UTF-8?q?=E2=9C=A8(backend)=20expose=20shortcut=20?= =?UTF-8?q?targets=20in=20the=20items=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcuts expose their target's id, title and a can_access flag so the frontend can grey out entries pointing to folders the user cannot open. The children listing prefetches the viewer accesses to keep the query count flat, and the tree includes shortcut entries. --- src/backend/core/api/serializers.py | 42 +++++ src/backend/core/api/viewsets.py | 22 ++- .../items/test_api_items_children_list.py | 18 ++ .../core/tests/items/test_api_items_list.py | 3 + .../tests/items/test_api_items_retrieve.py | 13 ++ .../core/tests/items/test_api_items_search.py | 6 + .../items/test_api_items_search_fulltext.py | 4 + .../tests/items/test_api_items_shortcuts.py | 176 ++++++++++++++++++ .../tests/items/test_api_items_trashbin.py | 1 + .../core/tests/items/test_api_items_tree.py | 13 ++ 10 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 src/backend/core/tests/items/test_api_items_shortcuts.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 115edcc3a..dcd922c61 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -11,6 +11,7 @@ from urllib.parse import quote from django.conf import settings +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -221,6 +222,43 @@ class Meta: ] +class ShortcutTargetSerializer(serializers.ModelSerializer): + """Serialize the restricted folder a shortcut points to.""" + + deleted = serializers.SerializerMethodField() + can_access = serializers.SerializerMethodField() + + class Meta: + model = models.Item + fields = ["id", "title", "is_restricted", "deleted", "can_access"] + read_only_fields = ["id", "title", "is_restricted", "deleted", "can_access"] + + def get_deleted(self, target) -> bool: + """Return whether the target is in the trash.""" + return target.deleted_at is not None + + def get_can_access(self, target) -> bool: + """Return whether the request user can open the target.""" + request = self.context.get("request") + user = request.user if request else None + if user is not None and user.is_authenticated: + accesses = getattr(target, "viewer_accesses", None) + if accesses is None: + has_access = models.ItemAccess.objects.filter( + Q(user=user) | Q(team__in=user.teams), + item=target, + ).exists() + else: + has_access = bool(accesses) + if has_access: + return True + return target.link_reach == LinkReachChoices.PUBLIC or ( + target.link_reach == LinkReachChoices.AUTHENTICATED + and user is not None + and user.is_authenticated + ) + + class ListItemSerializer(serializers.ModelSerializer): """Serialize items with limited fields for display in lists.""" @@ -234,6 +272,7 @@ class ListItemSerializer(serializers.ModelSerializer): creator = UserLightSerializer(read_only=True) hard_delete_at = serializers.SerializerMethodField(read_only=True) is_wopi_supported = serializers.SerializerMethodField() + target = ShortcutTargetSerializer(read_only=True, allow_null=True) class Meta: model = models.Item @@ -255,6 +294,7 @@ class Meta: "numchild", "numchild_folder", "path", + "target", "title", "updated_at", "user_role", @@ -288,6 +328,7 @@ class Meta: "link_reach", "nb_accesses", "path", + "target", "updated_at", "user_role", "type", @@ -487,6 +528,7 @@ class Meta: "numchild", "numchild_folder", "path", + "target", "title", "updated_at", "user_role", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 51a8f3f10..1d8e5e829 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1134,7 +1134,19 @@ def children(self, request, *args, **kwargs): ) # GET: List children - queryset = item.children().select_related("creator").filter(deleted_at__isnull=True) + queryset = ( + item.children().select_related("creator", "target").filter(deleted_at__isnull=True) + ) + if request.user.is_authenticated: + queryset = queryset.prefetch_related( + db.Prefetch( + "target__accesses", + queryset=models.ItemAccess.objects.filter( + db.Q(user=request.user) | db.Q(team__in=request.user.teams) + ), + to_attr="viewer_accesses", + ) + ) queryset = self._filter_suspicious_items(queryset, request.user) queryset = self._exclude_pending_items(queryset) queryset = self.filter_queryset(queryset) @@ -1231,8 +1243,12 @@ def tree(self, request, pk=None): paths_links_mapping[str(ancestor.path)] = ancestors_links.copy() tree = ( - self.queryset.select_related("creator") - .filter(clause, type=models.ItemTypeChoices.FOLDER, deleted_at__isnull=True) + self.queryset.select_related("creator", "target") + .filter( + clause, + type__in=[models.ItemTypeChoices.FOLDER, models.ItemTypeChoices.SHORTCUT], + deleted_at__isnull=True, + ) .order_by("created_at") ) 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 58476e048..2d824b594 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 @@ -48,6 +48,7 @@ def test_api_items_children_list_anonymous_public_standalone(): "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -93,6 +94,7 @@ def test_api_items_children_list_anonymous_public_standalone(): "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -178,6 +180,7 @@ def test_api_items_children_list_anonymous_public_parent(): "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -217,6 +220,7 @@ def test_api_items_children_list_anonymous_public_parent(): "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -310,6 +314,7 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -355,6 +360,7 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -437,6 +443,7 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent( "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -482,6 +489,7 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent( "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -589,6 +597,7 @@ def test_api_items_children_list_authenticated_related_direct(): "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -634,6 +643,7 @@ def test_api_items_children_list_authenticated_related_direct(): "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -719,6 +729,7 @@ def test_api_items_children_list_authenticated_related_parent(): "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -764,6 +775,7 @@ def test_api_items_children_list_authenticated_related_parent(): "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -906,6 +918,7 @@ def test_api_items_children_list_authenticated_related_team_members( "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -951,6 +964,7 @@ def test_api_items_children_list_authenticated_related_team_members( "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -1034,6 +1048,7 @@ def test_api_items_children_list_filter_type(): "id": str(child1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -1088,6 +1103,7 @@ def test_api_items_children_list_filter_type(): "id": str(child2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -1396,6 +1412,7 @@ def test_api_items_children_list_computed_link_reach_and_role(): "id": str(item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "restricted", "link_role": "reader", "numchild": 1, @@ -1445,6 +1462,7 @@ def test_api_items_children_list_computed_link_reach_and_role(): "id": str(child.id), "is_favorite": False, "is_restricted": False, + "target": None, "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 b4898f171..0fadd2381 100644 --- a/src/backend/core/tests/items/test_api_items_list.py +++ b/src/backend/core/tests/items/test_api_items_list.py @@ -107,6 +107,7 @@ def test_api_items_list_format(): "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": item3.link_reach, "link_role": item3.link_role, "nb_accesses": 0, @@ -146,6 +147,7 @@ def test_api_items_list_format(): "depth": 1, "is_favorite": True, "is_restricted": False, + "target": None, "link_reach": item2.link_reach, "link_role": item2.link_role, "nb_accesses": 3, @@ -185,6 +187,7 @@ def test_api_items_list_format(): "depth": 1, "is_favorite": True, "is_restricted": False, + "target": None, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 3, 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 7151987a2..d687824a8 100644 --- a/src/backend/core/tests/items/test_api_items_retrieve.py +++ b/src/backend/core/tests/items/test_api_items_retrieve.py @@ -44,6 +44,7 @@ def test_api_items_retrieve_anonymous_public_standalone(): "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 0, @@ -102,6 +103,7 @@ def test_api_items_retrieve_anonymous_public_parent(): "depth": 3, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 0, @@ -207,6 +209,7 @@ def test_api_items_retrieve_authenticated_unrelated_public_or_authenticated(reac "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": reach, "link_role": item.link_role, "nb_accesses": 0, @@ -271,6 +274,7 @@ def test_api_items_retrieve_authenticated_public_or_authenticated_parent(reach): "depth": 3, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 0, @@ -413,6 +417,7 @@ def test_api_items_retrieve_authenticated_related_direct(): "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 2, @@ -481,6 +486,7 @@ def test_api_items_retrieve_authenticated_related_parent(): "depth": 3, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 2, @@ -659,6 +665,7 @@ def test_api_items_retrieve_authenticated_related_team_members(teams, role, mock "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 5, @@ -735,6 +742,7 @@ def test_api_items_retrieve_authenticated_related_team_administrators(teams, rol "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 5, @@ -811,6 +819,7 @@ def test_api_items_retrieve_authenticated_related_team_owners(teams, mock_user_t "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 5, @@ -1211,6 +1220,7 @@ def test_api_items_retrieve_file_with_url_property(upload_state): "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 1, @@ -1285,6 +1295,7 @@ def test_api_items_retrieve_file_with_url_property_non_previewable(upload_state) "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 1, @@ -1349,6 +1360,7 @@ def test_api_items_retrieve_file_with_url_property_with_spaces(): "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "public", "link_role": item.link_role, "nb_accesses": 1, @@ -1495,6 +1507,7 @@ def test_api_items_retrieve_file_analysing_not_creator(): "depth": 1, "is_favorite": False, "is_restricted": False, + "target": None, "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 593dc5f14..1570c5a61 100644 --- a/src/backend/core/tests/items/test_api_items_search.py +++ b/src/backend/core/tests/items/test_api_items_search.py @@ -73,6 +73,7 @@ def test_api_items_search_authenticated_without_filters(): "id": str(top_parent.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": top_parent.link_reach, "link_role": top_parent.link_role, @@ -113,6 +114,7 @@ def test_api_items_search_authenticated_without_filters(): "id": str(parent.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": parent.link_reach, "link_role": parent.link_role, @@ -142,6 +144,7 @@ def test_api_items_search_authenticated_without_filters(): "id": str(top_parent.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": top_parent.link_reach, "link_role": top_parent.link_role, @@ -193,6 +196,7 @@ def test_api_items_search_authenticated_without_filters(): "id": str(children.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": children.link_reach, "link_role": children.link_role, @@ -222,6 +226,7 @@ def test_api_items_search_authenticated_without_filters(): "id": str(top_parent.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": top_parent.link_reach, "link_role": top_parent.link_role, @@ -261,6 +266,7 @@ def test_api_items_search_authenticated_without_filters(): "id": str(parent.id), "is_favorite": False, "is_restricted": False, + "target": None, "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 42d4185be..3ab933a92 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 @@ -83,6 +83,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "id": str(item_b.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": item_b.link_reach, "link_role": item_b.link_role, @@ -121,6 +122,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "hard_delete_at": None, "id": str(folder.id), "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": folder.link_reach, "link_role": folder.link_role, @@ -162,6 +164,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "id": str(item_c.id), "is_favorite": False, "is_restricted": False, + "target": None, "is_wopi_supported": False, "link_reach": item_c.link_reach, "link_role": item_c.link_role, @@ -200,6 +203,7 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "hard_delete_at": None, "id": str(folder.id), "is_restricted": False, + "target": None, "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_shortcuts.py b/src/backend/core/tests/items/test_api_items_shortcuts.py new file mode 100644 index 000000000..0b8e371db --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_shortcuts.py @@ -0,0 +1,176 @@ +"""Tests for the shortcut target details in the items API.""" + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def _create_restricted_folder(parent, user): + """Create a folder under parent and restrict it as user.""" + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + return folder.restrict(user) + + +def test_api_items_shortcuts_children_list_exposes_target(): + """The children listing exposes the shortcut target, greyed for excluded users.""" + parent_owner = factories.UserFactory() + owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + folder = _create_restricted_folder(parent, owner) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(parent_owner) + + response = client.get(f"/api/v1.0/items/{parent.id!s}/children/") + + assert response.status_code == 200 + results = {result["id"]: result for result in response.json()["results"]} + payload = results[str(shortcut.id)] + assert payload["type"] == "shortcut" + assert payload["target"] == { + "id": str(folder.id), + "title": folder.title, + "is_restricted": True, + "deleted": False, + "can_access": False, + } + + +def test_api_items_shortcuts_children_list_target_accessible(): + """The target is accessible for a user holding an explicit access on it.""" + user = factories.UserFactory() + owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "reader")], + ) + folder = _create_restricted_folder(parent, owner) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/items/{parent.id!s}/children/") + + assert response.status_code == 200 + results = {result["id"]: result for result in response.json()["results"]} + assert results[str(folder.shortcut.id)]["target"]["can_access"] is True + + +def test_api_items_shortcuts_children_list_target_accessible_via_link(): + """A public link reach on the target grants access through the shortcut.""" + parent_owner = factories.UserFactory() + owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + folder = _create_restricted_folder(parent, owner) + models.Item.objects.filter(pk=folder.pk).update(link_reach="public") + + client = APIClient() + client.force_login(parent_owner) + + response = client.get(f"/api/v1.0/items/{parent.id!s}/children/") + + assert response.status_code == 200 + results = {result["id"]: result for result in response.json()["results"]} + assert results[str(folder.shortcut.id)]["target"]["can_access"] is True + + +def test_api_items_shortcuts_retrieve_exposes_target(): + """Retrieving a shortcut exposes its target.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, user) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/items/{shortcut.id!s}/") + + assert response.status_code == 200 + assert response.json()["target"]["id"] == str(folder.id) + + +def test_api_items_shortcuts_non_shortcut_target_is_none(): + """Regular items expose a null target.""" + 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 response.json()["target"] is None + + +def test_api_items_shortcuts_children_list_constant_queries(): + """The number of queries does not grow with the number of shortcuts listed.""" + parent_owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + _create_restricted_folder(parent, factories.UserFactory()) + + client = APIClient() + client.force_login(parent_owner) + + # Warm the nb_accesses cache so both measures run in the same conditions + client.get(f"/api/v1.0/items/{parent.id!s}/children/") + + with CaptureQueriesContext(connection) as single: + response = client.get(f"/api/v1.0/items/{parent.id!s}/children/") + assert response.status_code == 200 + + for _ in range(3): + _create_restricted_folder(parent, factories.UserFactory()) + + with CaptureQueriesContext(connection) as many: + response = client.get(f"/api/v1.0/items/{parent.id!s}/children/") + assert response.status_code == 200 + assert len(response.json()["results"]) == 4 + + assert len(many) == len(single) + + +def test_api_items_shortcuts_tree_includes_shortcuts(): + """The tree endpoint includes shortcut entries so excluded users see them.""" + parent_owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + sibling = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = _create_restricted_folder(parent, factories.UserFactory()) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(parent_owner) + + response = client.get(f"/api/v1.0/items/{sibling.id!s}/tree/") + + assert response.status_code == 200 + tree = response.json() + children_ids = {child["id"] for child in tree["children"]} + assert str(shortcut.id) in children_ids 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 cf447800d..49aacb192 100644 --- a/src/backend/core/tests/items/test_api_items_trashbin.py +++ b/src/backend/core/tests/items/test_api_items_trashbin.py @@ -104,6 +104,7 @@ def test_api_items_trashbin_format(settings): "description": None, "hard_delete_at": ((now + timedelta(days=30)).isoformat()), "is_restricted": False, + "target": None, "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 f2eb0472a..8a099e0e1 100644 --- a/src/backend/core/tests/items/test_api_items_tree.py +++ b/src/backend/core/tests/items/test_api_items_tree.py @@ -159,6 +159,7 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "id": str(level2_1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level2_1.link_reach, "link_role": level2_1.link_role, "nb_accesses": 0, @@ -199,6 +200,7 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "id": str(level2_2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level2_2.link_reach, "link_role": level2_2.link_role, "nb_accesses": 0, @@ -233,6 +235,7 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "id": str(level1_2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level1_2.link_reach, "link_role": level1_2.link_role, "nb_accesses": 0, @@ -393,6 +396,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(level2_1.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level2_1.item.link_reach, "link_role": level2_1.item.link_role, "nb_accesses": 3, @@ -442,6 +446,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(level3_1.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level3_1.item.link_reach, "link_role": level3_1.item.link_role, "nb_accesses": 4, @@ -481,6 +486,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(level2_2.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level2_2.item.link_reach, "link_role": level2_2.item.link_role, "nb_accesses": 3, @@ -515,6 +521,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(level1_1.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level1_1.item.link_reach, "link_role": level1_1.item.link_role, "nb_accesses": 2, @@ -555,6 +562,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(level1_2.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level1_2.item.link_reach, "link_role": level1_2.item.link_role, "nb_accesses": 2, @@ -595,6 +603,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(level1_3.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": level1_3.item.link_reach, "link_role": level1_3.item.link_role, "nb_accesses": 2, @@ -629,6 +638,7 @@ def test_items_api_tree_authenticated_direct_access(django_assert_num_queries): "id": str(root.item.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": root.item.link_reach, "link_role": root.item.link_role, "nb_accesses": 1, @@ -746,6 +756,7 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "id": str(level1_1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "authenticated", "link_role": level1_1.link_role, "nb_accesses": 0, @@ -791,6 +802,7 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "id": str(level2_1.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "authenticated", "link_role": level2_1.link_role, "nb_accesses": 0, @@ -831,6 +843,7 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "id": str(level2_2.id), "is_favorite": False, "is_restricted": False, + "target": None, "link_reach": "authenticated", "link_role": level2_2.link_role, "nb_accesses": 0, From 7ff78056bf8d01b2c8ae0b16704c98ad7d0d81ef Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 17:18:40 +0200 Subject: [PATCH 17/22] =?UTF-8?q?=E2=9C=A8(backend)=20hide=20reachable=20r?= =?UTF-8?q?estricted=20roots=20from=20the=20top-level=20listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restricted folder lives at the tree root but its members reach it through the shortcut when they can open the containing folder. The listing hides the root in that case so the folder shows up in a single location, and keeps it for members without container access. --- src/backend/core/api/viewsets.py | 26 +++ .../items/test_api_items_list_restricted.py | 217 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 src/backend/core/tests/items/test_api_items_list_restricted.py diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1d8e5e829..8afbe75d7 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -763,6 +763,32 @@ def list(self, request, *args, **kwargs): ) queryset = queryset.filter(path__in=root_paths) + # Hide restricted roots the user already reaches through a live + # shortcut, so the folder shows up in a single location + if user.is_authenticated: + reachable_shortcuts = models.Item.objects.filter( + type=models.ItemTypeChoices.SHORTCUT, + target_id=db.OuterRef("pk"), + ancestors_deleted_at__isnull=True, + ).filter( + db.Exists( + models.ItemAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams), + item__path__ancestors=db.OuterRef("path"), + ) + ) + | db.Exists( + models.Item.objects.filter( + path__ancestors=db.OuterRef("path"), + link_reach__in=[ + LinkReachChoices.PUBLIC, + LinkReachChoices.AUTHENTICATED, + ], + ) + ) + ) + queryset = queryset.exclude(db.Q(is_restricted=True) & db.Exists(reachable_shortcuts)) + # Annotate the queryset with an attribute marking instances as highest ancestor # in order to save some time while computing abilities in the instance queryset = queryset.annotate( diff --git a/src/backend/core/tests/items/test_api_items_list_restricted.py b/src/backend/core/tests/items/test_api_items_list_restricted.py new file mode 100644 index 000000000..800f2b4f3 --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_list_restricted.py @@ -0,0 +1,217 @@ +"""Tests for restricted roots visibility in the items list API endpoint.""" + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def _create_restricted_folder(parent, user): + """Create a folder under parent and restrict it as user.""" + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + return folder.restrict(user) + + +def _listed_ids(client): + """Return the ids listed on the user's root listing.""" + response = client.get("/api/v1.0/items/") + assert response.status_code == 200 + return {result["id"] for result in response.json()["results"]} + + +def test_api_items_list_restricted_hidden_when_shortcut_reachable(): + """A member reaching the live shortcut does not see the restricted root.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + listed = _listed_ids(client) + assert str(parent.id) in listed + assert str(folder.id) not in listed + + +def test_api_items_list_restricted_visible_without_container_access(): + """A member without access to the container sees the restricted root.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + listed = _listed_ids(client) + assert str(folder.id) in listed + assert str(parent.id) not in listed + + +def test_api_items_list_restricted_hidden_via_team_access(mock_user_teams): + """A team access on the container hides the restricted root.""" + user = factories.UserFactory() + mock_user_teams.return_value = ["lasuite"] + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + factories.TeamItemAccessFactory(item=parent, team="lasuite", role="reader") + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + assert str(folder.id) not in _listed_ids(client) + + +def test_api_items_list_restricted_follows_moved_shortcut(): + """Moving the shortcut out of reach makes the restricted root visible again.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + other = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder.shortcut.move(other) + + client = APIClient() + client.force_login(user) + + assert str(folder.id) in _listed_ids(client) + + +def test_api_items_list_restricted_visible_when_shortcut_deleted(): + """Without a live shortcut the restricted root shows up for its members.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + models.Item.objects.filter(target=folder).delete() + + client = APIClient() + client.force_login(user) + + assert str(folder.id) in _listed_ids(client) + + +def test_api_items_list_restricted_visible_when_shortcut_trashed(): + """A shortcut under a soft deleted ancestor does not hide the restricted root.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + parent.soft_delete() + + client = APIClient() + client.force_login(user) + + assert str(folder.id) in _listed_ids(client) + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_items_list_restricted_hidden_via_link_on_container(reach): + """An open link on the container makes the shortcut reachable and hides the root.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=reach, + link_role="reader", + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + assert str(folder.id) not in _listed_ids(client) + + +def test_api_items_list_restricted_hidden_via_inherited_link(): + """A link inherited from a grandparent counts to reach the shortcut.""" + user = factories.UserFactory() + grandparent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach="public", + link_role="reader", + ) + parent = factories.ItemFactory( + parent=grandparent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + assert str(folder.id) not in _listed_ids(client) + + +def test_api_items_list_restricted_constant_queries(): + """The number of queries does not grow with the number of hidden restricted roots.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + # Warm the nb_accesses cache so both measures run in the same conditions + client.get("/api/v1.0/items/") + + with CaptureQueriesContext(connection) as single: + response = client.get("/api/v1.0/items/") + assert response.status_code == 200 + + for _ in range(3): + other = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=other, user=user, role="reader") + + with CaptureQueriesContext(connection) as many: + response = client.get("/api/v1.0/items/") + assert response.status_code == 200 + assert [result["id"] for result in response.json()["results"]] == [str(parent.id)] + + assert len(many) == len(single) + + +def test_api_items_list_restricted_differs_per_user(): + """The same restricted root is hidden or visible depending on the user.""" + insider = factories.UserFactory() + outsider = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(insider, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=insider, role="reader") + factories.UserItemAccessFactory(item=folder, user=outsider, role="reader") + + insider_client = APIClient() + insider_client.force_login(insider) + outsider_client = APIClient() + outsider_client.force_login(outsider) + + assert str(folder.id) not in _listed_ids(insider_client) + assert str(folder.id) in _listed_ids(outsider_client) From 6a581164af9cee3e2701cf5205ea2dd8f45aa3f1 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 09:54:43 +0200 Subject: [PATCH 18/22] =?UTF-8?q?=E2=9C=A8(backend)=20detach=20restricted?= =?UTF-8?q?=20folders=20by=20deleting=20their=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a shortcut removes the entry from the containing folder without trashing anything. An owner excluded from the target acts on the container only and can neither destroy, declassify nor read it: the folder keeps its accesses, stays restricted, and surfaces in its members' top-level listing. --- src/backend/core/api/viewsets.py | 5 ++- src/backend/core/models.py | 14 ++++++ .../tests/items/test_api_items_shortcuts.py | 43 +++++++++++++++++++ .../core/tests/test_models_items_shortcuts.py | 25 +++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8afbe75d7..fc500693e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -684,7 +684,10 @@ def perform_create(self, serializer): def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" - instance.soft_delete() + if instance.type == models.ItemTypeChoices.SHORTCUT: + instance.detach() + else: + instance.soft_delete() def perform_update(self, serializer): """Override to check if a file is renamed in order to rename file on storage.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d05e41119..c1a83e6c2 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1679,6 +1679,20 @@ def _normalize_explicit_link_reach(self): self.link_reach = None self.save(update_fields=["link_reach"]) + def detach(self): + """Delete this shortcut row, leaving its restricted target untouched.""" + if self.type != ItemTypeChoices.SHORTCUT: + raise ValidationError( + { + "type": ValidationError( + _("Only shortcuts can be detached"), + code="item_detach_not_a_shortcut", + ) + } + ) + + self._meta.model.objects.filter(pk=self.pk).delete() + @transaction.atomic def unrestrict(self): """Lift restriction and reattach the folder at its shortcut location.""" diff --git a/src/backend/core/tests/items/test_api_items_shortcuts.py b/src/backend/core/tests/items/test_api_items_shortcuts.py index 0b8e371db..f03fa9596 100644 --- a/src/backend/core/tests/items/test_api_items_shortcuts.py +++ b/src/backend/core/tests/items/test_api_items_shortcuts.py @@ -124,6 +124,49 @@ def test_api_items_shortcuts_non_shortcut_target_is_none(): assert response.json()["target"] is None +def test_api_items_shortcuts_delete_detaches_the_target(): + """Deleting a shortcut detaches the restricted folder without touching it.""" + parent_owner = factories.UserFactory() + owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + folder = _create_restricted_folder(parent, owner) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(parent_owner) + + response = client.delete(f"/api/v1.0/items/{shortcut.id!s}/") + + assert response.status_code == 204 + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + folder.refresh_from_db() + assert folder.is_restricted is True + assert folder.deleted_at is None + assert models.ItemAccess.objects.filter(item=folder, user=owner, role="owner").exists() + + +def test_api_items_shortcuts_delete_forbidden_for_reader(): + """A reader of the containing folder cannot detach a shortcut.""" + reader = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(reader, "reader")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(reader) + + response = client.delete(f"/api/v1.0/items/{shortcut.id!s}/") + + assert response.status_code == 403 + assert models.Item.objects.filter(pk=shortcut.pk).exists() + + def test_api_items_shortcuts_children_list_constant_queries(): """The number of queries does not grow with the number of shortcuts listed.""" parent_owner = factories.UserFactory() diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index 6580f2047..7801c6189 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -63,6 +63,31 @@ def test_models_items_shortcuts_move_rejects_own_target_subtree(): shortcut.move(folder) +def test_models_items_shortcuts_detach_deletes_the_row(): + """Detaching a shortcut deletes its row and leaves the target untouched.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + shortcut = folder.shortcut + + shortcut.detach() + + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + folder.refresh_from_db() + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() + + +def test_models_items_shortcuts_detach_rejects_other_types(): + """Only shortcuts can be detached.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + + with pytest.raises(ValidationError, match="Only shortcuts can be detached"): + folder.detach() + + def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): """The generic item factory should only draw folder and file types.""" types = {factories.ItemFactory().type for _ in range(20)} From c61c391b2b8bc1065e2f136603a07e963db71f4f Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 10:13:39 +0200 Subject: [PATCH 19/22] =?UTF-8?q?=E2=9C=A8(backend)=20detach=20subtree=20s?= =?UTF-8?q?hortcuts=20when=20an=20ancestor=20is=20trashed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trashing a folder must not drag restricted folders with it: their shortcuts are removed before the subtree is marked deleted, so each target survives untouched for its members while the rest of the branch goes to the trash as usual. Restoring the ancestor does not bring the shortcuts back. --- src/backend/core/models.py | 8 ++++ .../core/tests/test_models_items_shortcuts.py | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index c1a83e6c2..41138f710 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1420,6 +1420,14 @@ def soft_delete(self): "Cannot delete this item because one or more ancestors are already deleted." ) + # Restricted folders survive their container: delete (detach) the + # shortcuts of the subtree instead of trashing them + if self.type == ItemTypeChoices.FOLDER: + self.descendants().filter( + type=ItemTypeChoices.SHORTCUT, + ancestors_deleted_at__isnull=True, + ).delete() + self.ancestors_deleted_at = self.deleted_at = timezone.now() self.save(update_fields=["deleted_at", "ancestors_deleted_at"]) diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index 7801c6189..c044b7176 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -88,6 +88,43 @@ def test_models_items_shortcuts_detach_rejects_other_types(): folder.detach() +def test_models_items_shortcuts_ancestor_soft_delete_detaches(): + """Trashing an ancestor detaches the live shortcuts of its subtree.""" + user = factories.UserFactory() + grandparent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + parent = factories.ItemFactory(parent=grandparent, type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + shortcut = folder.shortcut + + grandparent.soft_delete() + + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + parent.refresh_from_db() + assert parent.ancestors_deleted_at is not None + folder.refresh_from_db() + assert folder.is_restricted is True + assert folder.deleted_at is None + assert folder.ancestors_deleted_at is None + + +def test_models_items_shortcuts_ancestor_restore_does_not_recreate(): + """Restoring a trashed ancestor does not bring detached shortcuts back.""" + user = factories.UserFactory() + grandparent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + parent = factories.ItemFactory(parent=grandparent, type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + grandparent.soft_delete() + + grandparent.restore() + + assert not models.Item.objects.filter(target=folder).exists() + folder.refresh_from_db() + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + + def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): """The generic item factory should only draw folder and file types.""" types = {factories.ItemFactory().type for _ in range(20)} From 8185191752fd74d38c506ec95fcda677ecf79b98 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 10:22:34 +0200 Subject: [PATCH 20/22] =?UTF-8?q?=E2=9C=A8(backend)=20detach=20the=20short?= =?UTF-8?q?cut=20when=20a=20restricted=20folder=20is=20trashed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit owner trashing a restricted folder leaves no entry pointing into the trash. The folder restores as a detached root, still restricted, reachable by its members from their listing. --- src/backend/core/models.py | 4 +++ .../core/tests/test_models_items_shortcuts.py | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 41138f710..05a9674ce 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1428,6 +1428,10 @@ def soft_delete(self): ancestors_deleted_at__isnull=True, ).delete() + # No shortcut may keep pointing into the trash + if self.is_restricted: + self._meta.model.objects.filter(target=self).delete() + self.ancestors_deleted_at = self.deleted_at = timezone.now() self.save(update_fields=["deleted_at", "ancestors_deleted_at"]) diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index c044b7176..9d41703d7 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -125,6 +125,40 @@ def test_models_items_shortcuts_ancestor_restore_does_not_recreate(): assert str(folder.path) == str(folder.id) +def test_models_items_shortcuts_target_soft_delete_detaches(): + """Trashing a restricted folder detaches its shortcut.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + shortcut = folder.shortcut + + folder.soft_delete() + + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + folder.refresh_from_db() + assert folder.deleted_at is not None + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + + +def test_models_items_shortcuts_target_restore_stays_detached(): + """A restored restricted folder comes back as a detached root.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + folder.soft_delete() + + folder.restore() + + folder.refresh_from_db() + assert folder.deleted_at is None + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + assert not models.Item.objects.filter(target=folder).exists() + + def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): """The generic item factory should only draw folder and file types.""" types = {factories.ItemFactory().type for _ in range(20)} From 18c3c9900f8780433859d1b9bd6e8012adfaf139 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 12:03:46 +0200 Subject: [PATCH 21/22] =?UTF-8?q?=E2=9C=A8(backend)=20exclude=20shortcuts?= =?UTF-8?q?=20from=20search,=20export=20and=20indexing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcuts are tree entries, not content: they never match a search, never reach the search index, and leave no entry in an exported archive. The target itself is indexed and exported through its own root, so users excluded from a restricted folder cannot find its content through search or an ancestor export. --- src/backend/core/api/viewsets.py | 3 +++ src/backend/core/services/item_exports.py | 4 +++ src/backend/core/services/search_indexers.py | 2 ++ .../core/tests/items/test_api_items_export.py | 26 +++++++++++++++++++ .../tests/items/test_api_items_shortcuts.py | 21 +++++++++++++++ .../tests/test_services_search_indexers.py | 16 ++++++++++++ 6 files changed, 72 insertions(+) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index fc500693e..92a9a5d8e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1437,6 +1437,9 @@ def search(self, request, *args, **kwargs): queryset = self._filter_suspicious_items(queryset, user) queryset = self._exclude_pending_items(queryset) + # Shortcuts are tree entries, not searchable content + queryset = queryset.exclude(type=models.ItemTypeChoices.SHORTCUT) + queryset = queryset.annotate_is_favorite(user) if workspace: diff --git a/src/backend/core/services/item_exports.py b/src/backend/core/services/item_exports.py index ae1a19b48..f2f7eb710 100644 --- a/src/backend/core/services/item_exports.py +++ b/src/backend/core/services/item_exports.py @@ -43,6 +43,10 @@ def export_descendants(folder): relative_paths = {str(folder.path): ""} for descendant in descendants: + # Shortcuts are tree entries: their target lives in another subtree + if descendant.type == models.ItemTypeChoices.SHORTCUT: + continue + parent_key = str(descendant.path).rsplit(".", 1)[0] parent_relative = relative_paths.get(parent_key) if parent_relative is None: diff --git a/src/backend/core/services/search_indexers.py b/src/backend/core/services/search_indexers.py index 10aef550a..25701d15c 100644 --- a/src/backend/core/services/search_indexers.py +++ b/src/backend/core/services/search_indexers.py @@ -196,6 +196,8 @@ def index(self, queryset=None, batch_size=None): queryset = queryset or models.Item.objects.filter( main_workspace=False, ) + # Shortcuts are tree entries, not indexable content + queryset = queryset.exclude(type=models.ItemTypeChoices.SHORTCUT) queryset = queryset.order_by("id") while True: diff --git a/src/backend/core/tests/items/test_api_items_export.py b/src/backend/core/tests/items/test_api_items_export.py index 970f3c766..e71801d1a 100644 --- a/src/backend/core/tests/items/test_api_items_export.py +++ b/src/backend/core/tests/items/test_api_items_export.py @@ -260,6 +260,32 @@ def test_api_items_export_file_missing_from_storage(): assert archive.read("gone.txt") == b"" +def test_api_items_export_skips_shortcuts(): + """Shortcuts leave no entry in the exported archive.""" + user = factories.UserFactory() + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, models.RoleChoices.OWNER)], + ) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"kept", + upload_bytes__filename="kept.txt", + ) + restricted = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FOLDER) + restricted.restrict(factories.UserFactory()) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert _zip_names(response) == ["kept.txt"] + + def test_api_items_export_empty_folder(): """Exporting an empty folder returns an empty zip archive.""" user = factories.UserFactory() diff --git a/src/backend/core/tests/items/test_api_items_shortcuts.py b/src/backend/core/tests/items/test_api_items_shortcuts.py index f03fa9596..eae241443 100644 --- a/src/backend/core/tests/items/test_api_items_shortcuts.py +++ b/src/backend/core/tests/items/test_api_items_shortcuts.py @@ -197,6 +197,27 @@ def test_api_items_shortcuts_children_list_constant_queries(): assert len(many) == len(single) +def test_api_items_shortcuts_excluded_from_search(): + """Shortcuts never show up as search results.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + shortcut = folder.shortcut + shortcut.title = "shortcut" + shortcut.save() + + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/items/search/", {"title": "shortcut"}) + + assert response.status_code == 200 + assert response.json()["results"] == [] + + def test_api_items_shortcuts_tree_includes_shortcuts(): """The tree endpoint includes shortcut entries so excluded users see them.""" parent_owner = factories.UserFactory() diff --git a/src/backend/core/tests/test_services_search_indexers.py b/src/backend/core/tests/test_services_search_indexers.py index 3063e20c7..ba36b85c9 100644 --- a/src/backend/core/tests/test_services_search_indexers.py +++ b/src/backend/core/tests/test_services_search_indexers.py @@ -484,6 +484,22 @@ def test_services_search_indexers_index_errors(indexer_settings): indexer.index() +@pytest.mark.usefixtures("indexer_settings") +@patch.object(SearchIndexer, "push") +def test_services_search_indexers_index_skips_shortcuts(mock_push): + """Shortcuts are never sent to the search index.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + count = SearchIndexer().index() + + assert count == 2 + indexed_ids = {doc["id"] for call in mock_push.call_args_list for doc in call.args[0]} + assert indexed_ids == {str(parent.id), str(folder.id)} + + @patch.object(SearchIndexer, "push") def test_services_search_indexers_batches_pass_only_batch_accesses(mock_push, indexer_settings): """ From 02cf95e9dc457eeb1258ac980ac1a52662e8c164 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 12:41:27 +0200 Subject: [PATCH 22/22] =?UTF-8?q?=E2=9C=A8(backend)=20allow=20restricting?= =?UTF-8?q?=20a=20folder=20at=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a child folder with is_restricted chains creation and activation atomically, so the folder starts as a restricted root with its shortcut in place. Only owners of the parent may use it: a lower role would promote itself to owner through restriction. Shortcuts themselves can never be created directly. --- CHANGELOG.md | 1 + src/backend/core/api/serializers.py | 13 +++ src/backend/core/api/viewsets.py | 27 ++++-- .../items/test_api_items_children_create.py | 86 ++++++++++++++++++- .../core/tests/items/test_api_items_create.py | 21 +++++ 5 files changed, 142 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f811be8..32562dc9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to - ✨(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 +- ✨(backend) allow restricting a folder at creation ### Fixed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index dcd922c61..02bdbdd05 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -640,6 +640,7 @@ class Meta: "creator", "depth", "is_favorite", + "is_restricted", "link_role", "link_reach", "nb_accesses", @@ -750,6 +751,18 @@ def validate(self, attrs): code="item_create_folder_title_required", ) + if attrs["type"] == models.ItemTypeChoices.SHORTCUT: + raise serializers.ValidationError( + {"type": _("Shortcuts can only be created by restricting a folder.")}, + code="item_create_shortcut_forbidden", + ) + + if attrs.get("is_restricted") and attrs["type"] != models.ItemTypeChoices.FOLDER: + raise serializers.ValidationError( + {"is_restricted": _("Only folders can be restricted.")}, + code="item_create_restricted_only_on_folders", + ) + return super().validate(attrs) def get_policy(self, item): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 92a9a5d8e..79be1e997 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -666,6 +666,12 @@ def _create_file_from_template(self, item, extension): def perform_create(self, serializer): """Set the current user as creator and owner of the newly created object.""" + if serializer.validated_data.get("is_restricted"): + raise drf.exceptions.ValidationError( + {"is_restricted": "A root folder cannot be restricted."}, + code="item_create_restricted_root", + ) + extension = serializer.validated_data.pop("extension", None) obj = models.Item.objects.create_child( @@ -1145,11 +1151,22 @@ def children(self, request, *args, **kwargs): extension = serializer.validated_data.pop("extension", None) - child_item = models.Item.objects.create_child( - creator=request.user, - parent=item, - **serializer.validated_data, - ) + # The child must be created unrestricted first: activation moves + # it to the root and refuses an already restricted folder + is_restricted = serializer.validated_data.pop("is_restricted", False) + if is_restricted and item.get_role(request.user) != models.RoleChoices.OWNER: + raise drf.exceptions.PermissionDenied( + "Only owners of the parent folder can create a restricted folder." + ) + + with transaction.atomic(): + child_item = models.Item.objects.create_child( + creator=request.user, + parent=item, + **serializer.validated_data, + ) + if is_restricted: + child_item = child_item.restrict(request.user) if extension: self._create_file_from_template(child_item, extension) diff --git a/src/backend/core/tests/items/test_api_items_children_create.py b/src/backend/core/tests/items/test_api_items_children_create.py index 1a72046d8..1338050c0 100644 --- a/src/backend/core/tests/items/test_api_items_children_create.py +++ b/src/backend/core/tests/items/test_api_items_children_create.py @@ -16,7 +16,7 @@ from core import factories from core.api.utils import sanitize_filename -from core.models import Item, ItemTypeChoices, LinkReachChoices, LinkRoleChoices +from core.models import Item, ItemAccess, ItemTypeChoices, LinkReachChoices, LinkRoleChoices pytestmark = pytest.mark.django_db @@ -414,6 +414,90 @@ def test_api_items_children_create_force_id_existing(): } +def test_api_items_children_create_restricted_by_parent_owner(): + """A parent owner can create a folder restricted from the start.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/items/{parent.id!s}/children/", + {"type": "folder", "title": "salaries", "is_restricted": True}, + format="json", + ) + + assert response.status_code == 201 + folder = Item.objects.get(id=response.json()["id"]) + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + assert str(folder.shortcut.path) == f"{parent.id!s}.{folder.shortcut.id!s}" + assert ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() + + +def test_api_items_children_create_restricted_forbidden_for_editor(): + """An editor of the parent cannot create a restricted folder.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=ItemTypeChoices.FOLDER, + users=[(user, "editor")], + ) + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/items/{parent.id!s}/children/", + {"type": "folder", "title": "salaries", "is_restricted": True}, + format="json", + ) + + assert response.status_code == 403 + assert not Item.objects.filter(title="salaries").exists() + + +def test_api_items_children_create_restricted_file_rejected(): + """A file cannot be created restricted.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/items/{parent.id!s}/children/", + {"type": "file", "filename": "file.txt", "is_restricted": True}, + format="json", + ) + + assert response.status_code == 400 + assert response.json()["errors"][0]["code"] == "item_create_restricted_only_on_folders" + + +def test_api_items_children_create_shortcut_rejected(): + """A shortcut cannot be created directly through the API.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + target = factories.ItemFactory(type=ItemTypeChoices.FOLDER, is_restricted=True) + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/items/{parent.id!s}/children/", + {"type": "shortcut", "title": "sneaky", "target": str(target.id)}, + format="json", + ) + + assert response.status_code == 400 + + @pytest.mark.parametrize( "item_type", [type for type in ItemTypeChoices.values if type != ItemTypeChoices.FOLDER] ) diff --git a/src/backend/core/tests/items/test_api_items_create.py b/src/backend/core/tests/items/test_api_items_create.py index 5756aae82..58294b2b3 100644 --- a/src/backend/core/tests/items/test_api_items_create.py +++ b/src/backend/core/tests/items/test_api_items_create.py @@ -59,6 +59,27 @@ def test_api_items_create_authenticated_success(): assert item.type == ItemTypeChoices.FOLDER +def test_api_items_create_restricted_root_rejected(): + """A root item cannot be created restricted: no parent can hold its shortcut.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/items/", + { + "title": "my item", + "type": ItemTypeChoices.FOLDER, + "is_restricted": True, + }, + format="json", + ) + + assert response.status_code == 400 + assert not Item.objects.exists() + + def test_api_items_create_file_authenticated_no_filename(): """ Creating a file item without providing a filename should fail.