From 6d6b6652aca66ed2494e85d94267a56adefbfff3 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 30 Jun 2026 16:58:02 +0200 Subject: [PATCH 01/30] =?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 1271f7862f274afb3c39d227bbb1e0655ab55640 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 14:28:05 +0200 Subject: [PATCH 02/30] =?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 152d6ed0d..8618fe71b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,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 aacdf51d1..38b96dafe 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 @@ -1197,9 +1198,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 @@ -1239,10 +1238,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 57c6c39c1..c6e7edfd9 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 32b4c69352f997da09d7a64fba81e777e73ea9ba Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 14:35:20 +0200 Subject: [PATCH 03/30] =?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 38b96dafe..fa7c99165 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__) @@ -1318,93 +1317,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 afe8f5bead3900ff1b960cabca3f1a69b1992b4c Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 17:46:29 +0200 Subject: [PATCH 04/30] =?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 d4d6b5025c2702a38f77959aa82e4dff70a82476 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 16:38:52 +0200 Subject: [PATCH 05/30] =?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 b3e343d23..d22fee8a8 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 @@ -744,15 +746,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 ) @@ -784,12 +779,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 76529ded2de927e71be93f2a00d65cd4a52fe720 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 6 Jul 2026 16:38:53 +0200 Subject: [PATCH 06/30] =?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 ee1f4ba51eaeb329875596f18246cc45624a573b Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Wed, 1 Jul 2026 19:58:54 +0200 Subject: [PATCH 07/30] =?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 8618fe71b..726449d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,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 fa7c99165..17845f9ce 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1221,6 +1221,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 2186e62405943cb1868885453dea882b398a51eb Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Fri, 24 Jul 2026 17:12:50 +0200 Subject: [PATCH 08/30] =?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 17845f9ce..54e51cf61 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) quota_excluded = models.BooleanField( @@ -1049,7 +1050,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 2e688c44a175115293301598f78bd58447591846 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Fri, 24 Jul 2026 17:34:19 +0200 Subject: [PATCH 09/30] =?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 54e51cf61..127da8cc1 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) quota_excluded = models.BooleanField( @@ -1055,6 +1063,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 776005e3bffa9b823c1676d21cc305294ccad535 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 10:32:41 +0200 Subject: [PATCH 10/30] =?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 6d671643dbfc58b00161739ac4590acd86bdec24 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 11:01:22 +0200 Subject: [PATCH 11/30] =?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 127da8cc1..1eb3ca5f1 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1545,6 +1545,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}" @@ -1559,6 +1583,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 dd55508a5a6e18f4bc29f3e1f0e1c6fafb4f69a3 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 11:53:11 +0200 Subject: [PATCH 12/30] =?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 1eb3ca5f1..a73d13209 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.""" @@ -1651,6 +1651,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 a65e03fab1db5b7fb9c9a274d346bd0f2b8c3050 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 12:30:32 +0200 Subject: [PATCH 13/30] =?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 a73d13209..65f2f1c92 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1651,6 +1651,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.""" @@ -1684,6 +1705,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 b3f7c9c1ea8fdbfa51b17a666538f988750005fb Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 13:17:25 +0200 Subject: [PATCH 14/30] =?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 65f2f1c92..c201b0a3e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1672,6 +1672,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.""" @@ -1706,6 +1717,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 a07f85716c439fcdc904249ea9bd3a86a79f02c0 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 14:44:52 +0200 Subject: [PATCH 15/30] =?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 726449d74..f3f79ed8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,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 d22fee8a8..9240ca1e5 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 enums, 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 02e905a4e04b4e556bc8ce7a51a9a009daf95a3c Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 16:31:41 +0200 Subject: [PATCH 16/30] =?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 9240ca1e5..1d9447733 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 e09260ab5..ca917d60e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1141,7 +1141,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) @@ -1238,8 +1250,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 07c4ba831d0d9b9dc4ef59f2fca2859875b512a9 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 17:18:40 +0200 Subject: [PATCH 17/30] =?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 ca917d60e..fe9091b38 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -768,6 +768,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 d4c69a08ca18d27e48c93b6f83c8f03d6910f4be Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 09:54:43 +0200 Subject: [PATCH 18/30] =?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 fe9091b38..ae167dd8a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -689,7 +689,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 c201b0a3e..f835ec83e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1683,6 +1683,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 97db93b13c48521dd9e1b8c3d7f22b4c6dcc1f4b Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 10:13:39 +0200 Subject: [PATCH 19/30] =?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 f835ec83e..4da292a86 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1424,6 +1424,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 161cb0ea894a6066960f5b2e16ef58201f6b7703 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 10:22:34 +0200 Subject: [PATCH 20/30] =?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 4da292a86..95a298394 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1432,6 +1432,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 1220ebe5a07644b2eabec62fae9401ffb3eee65b Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 12:03:46 +0200 Subject: [PATCH 21/30] =?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 ae167dd8a..9c353fdbe 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1444,6 +1444,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 ccb9fc1ecd7a62a9813044fd15ae2d2df7a5a975 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 12:41:27 +0200 Subject: [PATCH 22/30] =?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 | 29 +++++-- .../items/test_api_items_children_create.py | 86 ++++++++++++++++++- .../core/tests/items/test_api_items_create.py | 21 +++++ 5 files changed, 143 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3f79ed8d..4c2e5dd1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,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 1d9447733..63d512fd4 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -636,6 +636,7 @@ class Meta: "creator", "depth", "is_favorite", + "is_restricted", "link_role", "link_reach", "nb_accesses", @@ -746,6 +747,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 9c353fdbe..5b1b27f4a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -670,6 +670,12 @@ def get_create_extra_attributes(self): 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( @@ -1151,12 +1157,23 @@ 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, - **self.get_create_extra_attributes(), - ) + # 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, + **self.get_create_extra_attributes(), + ) + 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 436d9eb9a..38e23fbc1 100644 --- a/src/backend/core/tests/items/test_api_items_create.py +++ b/src/backend/core/tests/items/test_api_items_create.py @@ -82,6 +82,27 @@ def test_api_items_create_authenticated_ignores_aud_item_attributes(): assert item.quota_excluded is False +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. From f3f9c90102588971261e55b1f5ba64063d06e51d Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 4 Aug 2026 10:52:49 +0200 Subject: [PATCH 23/30] =?UTF-8?q?fixup!=20=E2=99=BB=EF=B8=8F(backend)=20ex?= =?UTF-8?q?tract=20role=20resolution=20into=20a=20permissions=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/permissions/backends/base.py | 8 ++++---- src/backend/core/permissions/backends/role.py | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/backend/core/permissions/backends/base.py b/src/backend/core/permissions/backends/base.py index ba1438684..5c12f4d4b 100644 --- a/src/backend/core/permissions/backends/base.py +++ b/src/backend/core/permissions/backends/base.py @@ -25,14 +25,14 @@ def effective_accesses(self, item: models.Item) -> QuerySet[models.ItemAccess]: 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.""" - @abstractmethod def abilities(self, user: models.User | AnonymousUser, item: models.Item) -> dict: """Compute and return abilities for a given user on the 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 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 fbe5bf6f9..f83268aef 100644 --- a/src/backend/core/permissions/backends/role.py +++ b/src/backend/core/permissions/backends/role.py @@ -216,10 +216,6 @@ def roles_at(self, user: models.User | AnonymousUser, path: str) -> QuerySet[str item__path__ancestors=path, ).values_list("role", flat=True) - 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: models.User | AnonymousUser, item: models.Item ) -> dict[str, bool | dict[str, list[str]]]: From 02b7dd6ef9bde2eaf5016ba8dedf3a519016d3e1 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 4 Aug 2026 10:58:07 +0200 Subject: [PATCH 24/30] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20add=20short?= =?UTF-8?q?cut=20item=20type=20targeting=20another=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0028_item_add_shortcut_target.py | 6 +++++- src/backend/core/models.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/backend/core/migrations/0028_item_add_shortcut_target.py b/src/backend/core/migrations/0028_item_add_shortcut_target.py index f0f7defcd..e0e765ec4 100644 --- a/src/backend/core/migrations/0028_item_add_shortcut_target.py +++ b/src/backend/core/migrations/0028_item_add_shortcut_target.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): 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'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shortcuts', to='core.item'), ), migrations.AlterField( model_name='item', @@ -25,4 +25,8 @@ class Migration(migrations.Migration): 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'), ), + migrations.AddConstraint( + model_name='item', + constraint=models.UniqueConstraint(fields=('target',), name='unique_shortcut_per_target'), + ), ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 95a298394..f95e0e5bd 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1021,10 +1021,10 @@ class Item(TreeModel, BaseModel): ) mimetype = models.CharField(max_length=255, null=True, blank=True) is_restricted = models.BooleanField(default=False) - target = models.OneToOneField( + target = models.ForeignKey( "self", on_delete=models.CASCADE, - related_name="shortcut", + related_name="shortcuts", null=True, blank=True, ) @@ -1070,6 +1070,10 @@ class Meta: ), name="check_target_only_on_shortcuts", ), + models.UniqueConstraint( + fields=["target"], + name="unique_shortcut_per_target", + ), ] indexes = [ GistIndex(fields=["path"]), @@ -1247,6 +1251,11 @@ def parent(self): return self._meta.model.objects.filter(path=str(self.path[:-1])).first() return None + @property + def shortcut(self): + """Return the shortcut targeting this item, if any.""" + return self._meta.model.objects.filter(target=self).first() + def invalidate_nb_accesses_cache(self): """ Invalidate the cache for number of accesses, including on affected descendants. From f6f9402aef0516f5ea95c339d16bce5c6c0abebf Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 4 Aug 2026 11:02:58 +0200 Subject: [PATCH 25/30] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20detach=20su?= =?UTF-8?q?btree=20shortcuts=20when=20an=20ancestor=20is=20trashed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/models.py | 8 -------- .../items/test_api_items_list_restricted.py | 20 +++++++++++++++++++ .../core/tests/test_models_items_shortcuts.py | 19 ++++++++++-------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f95e0e5bd..907f85511 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1433,14 +1433,6 @@ 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() - # No shortcut may keep pointing into the trash if self.is_restricted: self._meta.model.objects.filter(target=self).delete() 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 index 800f2b4f3..7dc3809dc 100644 --- a/src/backend/core/tests/items/test_api_items_list_restricted.py +++ b/src/backend/core/tests/items/test_api_items_list_restricted.py @@ -124,6 +124,26 @@ def test_api_items_list_restricted_visible_when_shortcut_trashed(): assert str(folder.id) in _listed_ids(client) +def test_api_items_list_restricted_hidden_again_after_restore(): + """Restoring the trashed container hides the restricted root 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") + parent.soft_delete() + parent.restore() + + client = APIClient() + client.force_login(user) + + listed = _listed_ids(client) + assert str(parent.id) in listed + assert str(folder.id) not in listed + + @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.""" diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index 9d41703d7..d781c1e34 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -88,8 +88,8 @@ 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.""" +def test_models_items_shortcuts_ancestor_soft_delete_trashes_them(): + """Trashing an ancestor trashes 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) @@ -99,27 +99,30 @@ def test_models_items_shortcuts_ancestor_soft_delete_detaches(): 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 + shortcut.refresh_from_db() + assert shortcut.deleted_at is None + assert shortcut.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.""" +def test_models_items_shortcuts_ancestor_restore_brings_them_back(): + """Restoring a trashed ancestor restores the 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() grandparent.restore() - assert not models.Item.objects.filter(target=folder).exists() + shortcut.refresh_from_db() + assert shortcut.deleted_at is None + assert shortcut.ancestors_deleted_at is None folder.refresh_from_db() assert folder.is_restricted is True assert str(folder.path) == str(folder.id) From 9e6a1a4ef070018335f784d2c9619622afa42146 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 4 Aug 2026 11:03:13 +0200 Subject: [PATCH 26/30] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20detach=20th?= =?UTF-8?q?e=20shortcut=20when=20a=20restricted=20folder=20is=20trashed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/models.py | 32 ++++++++++-- .../core/tests/test_models_items_shortcuts.py | 51 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 907f85511..f14dcd1f6 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1433,14 +1433,21 @@ def soft_delete(self): "Cannot delete this item because one or more ancestors are already deleted." ) - # 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"]) + # The shortcut of a restricted folder lives in another subtree: trash + # it along so that restoring the folder can bring it back + if self.is_restricted: + self._meta.model.objects.filter( + target=self, + ancestors_deleted_at__isnull=True, + ).update( + deleted_at=self.deleted_at, + ancestors_deleted_at=self.deleted_at, + ) + # Mark all descendants as soft deleted if self.type == ItemTypeChoices.FOLDER: self.descendants().filter(ancestors_deleted_at__isnull=True).update( @@ -1488,6 +1495,12 @@ def hard_delete(self): # Mark all descendants as hard deleted self.descendants().update(hard_deleted_at=self.hard_deleted_at) + # The shortcut of a restricted folder must not survive its target + if self.is_restricted: + self._meta.model.objects.filter(target=self).update( + hard_deleted_at=self.hard_deleted_at + ) + transaction.on_commit(lambda: invalidate_storage_used_cache(creator_ids)) @transaction.atomic @@ -1543,6 +1556,17 @@ def restore(self): | models.Q(ancestors_deleted_at__lt=current_deleted_at) ).update(ancestors_deleted_at=None) + # Bring back the shortcut trashed along the restricted folder, unless + # its own subtree went to the trash meanwhile + if self.is_restricted: + shortcut = self._meta.model.objects.filter( + target=self, deleted_at=current_deleted_at + ).first() + if shortcut and not shortcut.ancestors().filter(deleted_at__isnull=False).exists(): + self._meta.model.objects.filter(pk=shortcut.pk).update( + deleted_at=None, ancestors_deleted_at=None + ) + @transaction.atomic def move(self, target): """ diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index d781c1e34..8302669bd 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -128,8 +128,8 @@ def test_models_items_shortcuts_ancestor_restore_brings_them_back(): assert str(folder.path) == str(folder.id) -def test_models_items_shortcuts_target_soft_delete_detaches(): - """Trashing a restricted folder detaches its shortcut.""" +def test_models_items_shortcuts_target_soft_delete_trashes_its_shortcut(): + """Trashing a restricted folder trashes its shortcut along.""" user = factories.UserFactory() parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) @@ -138,28 +138,67 @@ def test_models_items_shortcuts_target_soft_delete_detaches(): folder.soft_delete() - assert not models.Item.objects.filter(pk=shortcut.pk).exists() folder.refresh_from_db() + shortcut.refresh_from_db() + assert shortcut.deleted_at == folder.deleted_at + assert shortcut.ancestors_deleted_at == folder.deleted_at 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.""" +def test_models_items_shortcuts_target_restore_restores_its_shortcut(): + """Restoring a restricted folder restores 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() folder.restore() folder.refresh_from_db() + shortcut.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() + assert shortcut.deleted_at is None + assert shortcut.ancestors_deleted_at is None + + +def test_models_items_shortcuts_target_restore_leaves_shortcut_in_trashed_subtree(): + """The shortcut stays in the trash when its own subtree was trashed meanwhile.""" + 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() + parent.soft_delete() + + folder.restore() + + folder.refresh_from_db() + shortcut.refresh_from_db() + assert folder.deleted_at is None + assert str(folder.path) == str(folder.id) + assert shortcut.deleted_at is not None + + +def test_models_items_shortcuts_target_hard_delete_marks_the_shortcut(): + """Hard deleting a restricted folder hard deletes 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() + + folder.hard_delete() + + shortcut.refresh_from_db() + assert shortcut.hard_deleted_at is not None def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): From 3c85a855f95a1c551b9def55d5d4d38bc66f493f Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 4 Aug 2026 15:16:26 +0200 Subject: [PATCH 27/30] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20detach=20th?= =?UTF-8?q?e=20shortcut=20when=20a=20restricted=20folder=20is=20trashed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/tests/test_models_items_shortcuts.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index 8302669bd..a6e0d6090 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -186,6 +186,25 @@ def test_models_items_shortcuts_target_restore_leaves_shortcut_in_trashed_subtre assert shortcut.deleted_at is not None +def test_models_items_shortcuts_target_restore_ignores_previous_cycle_shortcut(): + """A shortcut trashed in a previous cycle is not revived by a later restore.""" + 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() + parent.soft_delete() + folder.restore() # the shortcut stays trashed with the first timestamp + parent.restore() + + folder.soft_delete() + folder.restore() + + shortcut.refresh_from_db() + assert shortcut.deleted_at is not None + + def test_models_items_shortcuts_target_hard_delete_marks_the_shortcut(): """Hard deleting a restricted folder hard deletes its shortcut.""" user = factories.UserFactory() From f005e710ec8b82e3bbcf011471b1e7ba964461b9 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Thu, 6 Aug 2026 09:04:02 +0200 Subject: [PATCH 28/30] =?UTF-8?q?fixup!=20=F0=9F=94=A7(dev)=20align=20demo?= =?UTF-8?q?=20passwords=20with=20keycloak=20realm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/demo/defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/demo/defaults.py b/src/backend/demo/defaults.py index ccd36c274..607adcd4d 100644 --- a/src/backend/demo/defaults.py +++ b/src/backend/demo/defaults.py @@ -4,7 +4,7 @@ USERS = [ { - "email": "page.turner@library.book", + "email": "paige.turner@library.book", "full_name": "Paige Turner", "short_name": "Paige", }, From c01e65954c61b8f99f557fa9299cba42e0efa07e Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Thu, 13 Aug 2026 18:31:09 +0200 Subject: [PATCH 29/30] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20add=20is=5F?= =?UTF-8?q?restricted=20field=20to=20Item=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...item_add_is_restricted.py => 0029_item_add_is_restricted.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/backend/core/migrations/{0027_item_add_is_restricted.py => 0029_item_add_is_restricted.py} (90%) diff --git a/src/backend/core/migrations/0027_item_add_is_restricted.py b/src/backend/core/migrations/0029_item_add_is_restricted.py similarity index 90% rename from src/backend/core/migrations/0027_item_add_is_restricted.py rename to src/backend/core/migrations/0029_item_add_is_restricted.py index 61a5cd170..3f397af97 100644 --- a/src/backend/core/migrations/0027_item_add_is_restricted.py +++ b/src/backend/core/migrations/0029_item_add_is_restricted.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0026_item_item_creator_size_not_hdel_idx'), + ('core', '0028_item_creator_size_quota_idx'), ] operations = [ From 47721edba6bc3d0e042f76c4abec8b946ed414a9 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Thu, 13 Aug 2026 18:31:32 +0200 Subject: [PATCH 30/30] =?UTF-8?q?fixup!=20=E2=9C=A8(backend)=20add=20short?= =?UTF-8?q?cut=20item=20type=20targeting=20another=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._add_shortcut_target.py => 0030_item_add_shortcut_target.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/backend/core/migrations/{0028_item_add_shortcut_target.py => 0030_item_add_shortcut_target.py} (96%) diff --git a/src/backend/core/migrations/0028_item_add_shortcut_target.py b/src/backend/core/migrations/0030_item_add_shortcut_target.py similarity index 96% rename from src/backend/core/migrations/0028_item_add_shortcut_target.py rename to src/backend/core/migrations/0030_item_add_shortcut_target.py index e0e765ec4..2c2760a90 100644 --- a/src/backend/core/migrations/0028_item_add_shortcut_target.py +++ b/src/backend/core/migrations/0030_item_add_shortcut_target.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0027_item_add_is_restricted'), + ('core', '0029_item_add_is_restricted'), ] operations = [