diff --git a/CHANGELOG.md b/CHANGELOG.md index 152d6ed0d..4c2e5dd1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ 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 +- ✨(backend) add restricted access on folders, detached behind a shortcut +- ✨(backend) allow restricting a folder at creation ### Fixed @@ -52,6 +55,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/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/core/api/serializers.py b/src/backend/core/api/serializers.py index b3e343d23..63d512fd4 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 @@ -9,11 +11,12 @@ 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 _ 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 @@ -219,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.""" @@ -232,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 @@ -248,10 +289,12 @@ class Meta: "is_favorite", "link_role", "link_reach", + "is_restricted", "nb_accesses", "numchild", "numchild_folder", "path", + "target", "title", "updated_at", "user_role", @@ -280,10 +323,12 @@ class Meta: "creator", "depth", "is_favorite", + "is_restricted", "link_role", "link_reach", "nb_accesses", "path", + "target", "updated_at", "user_role", "type", @@ -478,10 +523,12 @@ class Meta: "is_favorite", "link_role", "link_reach", + "is_restricted", "nb_accesses", "numchild", "numchild_folder", "path", + "target", "title", "updated_at", "user_role", @@ -534,7 +581,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")) @@ -577,6 +636,7 @@ class Meta: "creator", "depth", "is_favorite", + "is_restricted", "link_role", "link_reach", "nb_accesses", @@ -687,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): @@ -744,15 +816,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 +849,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 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index e09260ab5..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( @@ -689,7 +695,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.""" @@ -768,6 +777,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( @@ -1122,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) @@ -1141,7 +1187,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 +1296,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") ) @@ -1399,6 +1461,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/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/0029_item_add_is_restricted.py b/src/backend/core/migrations/0029_item_add_is_restricted.py new file mode 100644 index 000000000..3f397af97 --- /dev/null +++ b/src/backend/core/migrations/0029_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', '0028_item_creator_size_quota_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/migrations/0030_item_add_shortcut_target.py b/src/backend/core/migrations/0030_item_add_shortcut_target.py new file mode 100644 index 000000000..2c2760a90 --- /dev/null +++ b/src/backend/core/migrations/0030_item_add_shortcut_target.py @@ -0,0 +1,32 @@ +# 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', '0029_item_add_is_restricted'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='target', + 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', + 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'), + ), + 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 aacdf51d1..f14dcd1f6 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -42,9 +42,9 @@ 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 logger = getLogger(__name__) @@ -71,6 +71,7 @@ class ItemTypeChoices(models.TextChoices): FOLDER = "folder", _("Folder") FILE = "file", _("File") + SHORTCUT = "shortcut", _("Shortcut") class ItemUploadStateChoices(models.TextChoices): @@ -981,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.""" @@ -1019,6 +1020,14 @@ class Item(TreeModel, BaseModel): blank=True, ) mimetype = models.CharField(max_length=255, null=True, blank=True) + is_restricted = models.BooleanField(default=False) + target = models.ForeignKey( + "self", + on_delete=models.CASCADE, + related_name="shortcuts", + null=True, + blank=True, + ) main_workspace = models.BooleanField(default=False) size = models.BigIntegerField(null=True, blank=True) quota_excluded = models.BooleanField( @@ -1049,7 +1058,22 @@ 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", + ), + 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", + ), + models.UniqueConstraint( + fields=["target"], + name="unique_shortcut_per_target", + ), ] indexes = [ GistIndex(fields=["path"]), @@ -1197,9 +1221,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 @@ -1223,6 +1245,17 @@ 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 + + @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. @@ -1239,10 +1272,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) @@ -1322,93 +1352,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.""" @@ -1492,6 +1437,17 @@ def soft_delete(self): 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( @@ -1539,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 @@ -1594,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): """ @@ -1609,6 +1582,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}" @@ -1623,6 +1620,160 @@ 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 + + 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() + + 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"]) + + 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.""" + 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._normalize_explicit_accesses() + self._normalize_explicit_link_reach() + + self.invalidate_nb_accesses_cache() + + return self + class MirrorItemTask(BaseModel): """Model managing a status for a mirroring task.""" 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..5c12f4d4b --- /dev/null +++ b/src/backend/core/permissions/backends/base.py @@ -0,0 +1,38 @@ +"""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 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 new file mode 100644 index 000000000..f83268aef --- /dev/null +++ b/src/backend/core/permissions/backends/role.py @@ -0,0 +1,223 @@ +"""Role-based permissions backend.""" + +from __future__ import annotations + +from functools import cached_property + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.db.models import Q, QuerySet + +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 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_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.""" + 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, + "restrict": self.can_restrict, + "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: 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: 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 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.""" + return ItemAbilities(user, item).as_dict() 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/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_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..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 @@ -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 @@ -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,7 +414,94 @@ def test_api_items_children_create_force_id_existing(): } -def test_api_items_children_create_not_a_folder(): +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] +) +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 +510,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/items/test_api_items_children_list.py b/src/backend/core/tests/items/test_api_items_children_list.py index 5321d138e..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 @@ -47,6 +47,8 @@ def test_api_items_children_list_anonymous_public_standalone(): "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -91,6 +93,8 @@ def test_api_items_children_list_anonymous_public_standalone(): "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -175,6 +179,8 @@ def test_api_items_children_list_anonymous_public_parent(): "depth": 4, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -213,6 +219,8 @@ def test_api_items_children_list_anonymous_public_parent(): "depth": 4, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -305,6 +313,8 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -349,6 +359,8 @@ def test_api_items_children_list_authenticated_unrelated_public_or_authenticated "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -430,6 +442,8 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent( "depth": 4, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -474,6 +488,8 @@ def test_api_items_children_list_authenticated_public_or_authenticated_parent( "depth": 4, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -580,6 +596,8 @@ def test_api_items_children_list_authenticated_related_direct(): "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -624,6 +642,8 @@ def test_api_items_children_list_authenticated_related_direct(): "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -708,6 +728,8 @@ def test_api_items_children_list_authenticated_related_parent(): "depth": 4, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -752,6 +774,8 @@ def test_api_items_children_list_authenticated_related_parent(): "depth": 4, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -893,6 +917,8 @@ def test_api_items_children_list_authenticated_related_team_members( "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -937,6 +963,8 @@ def test_api_items_children_list_authenticated_related_team_members( "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -1019,6 +1047,8 @@ def test_api_items_children_list_filter_type(): "depth": 2, "id": str(child1.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -1072,6 +1102,8 @@ def test_api_items_children_list_filter_type(): "depth": 2, "id": str(child2.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -1379,6 +1411,8 @@ def test_api_items_children_list_computed_link_reach_and_role(): "depth": 3, "id": str(item.id), "is_favorite": False, + "is_restricted": False, + "target": None, "link_reach": "restricted", "link_role": "reader", "numchild": 1, @@ -1427,6 +1461,8 @@ def test_api_items_children_list_computed_link_reach_and_role(): "depth": 4, "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_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. 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_list.py b/src/backend/core/tests/items/test_api_items_list.py index b3e01478b..0fadd2381 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,8 @@ 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, @@ -144,6 +146,8 @@ 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, @@ -182,6 +186,8 @@ 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_list_restricted.py b/src/backend/core/tests/items/test_api_items_list_restricted.py new file mode 100644 index 000000000..7dc3809dc --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_list_restricted.py @@ -0,0 +1,237 @@ +"""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) + + +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.""" + 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) 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..d687824a8 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,8 @@ 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, @@ -100,6 +102,8 @@ 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, @@ -204,6 +208,8 @@ 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, @@ -267,6 +273,8 @@ 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, @@ -408,6 +416,8 @@ 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, + "target": None, "link_reach": item.link_reach, "link_role": item.link_role, "nb_accesses": 2, @@ -475,6 +485,8 @@ 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, + "target": None, "link_reach": "restricted", "link_role": item.link_role, "nb_accesses": 2, @@ -652,6 +664,8 @@ 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, @@ -727,6 +741,8 @@ 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, @@ -802,6 +818,8 @@ 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, @@ -1201,6 +1219,8 @@ 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, @@ -1274,6 +1294,8 @@ 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, @@ -1337,6 +1359,8 @@ 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, @@ -1482,6 +1506,8 @@ 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 3595067dc..1570c5a61 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,8 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "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, @@ -111,6 +113,8 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "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, @@ -139,6 +143,8 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "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, @@ -189,6 +195,8 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "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, @@ -217,6 +225,8 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "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, @@ -255,6 +265,8 @@ def test_api_items_search_authenticated_without_filters(): "hard_delete_at": None, "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 4c2b9732b..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 @@ -82,6 +82,8 @@ 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, + "target": None, "is_wopi_supported": False, "link_reach": item_b.link_reach, "link_role": item_b.link_role, @@ -119,6 +121,8 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "filename": None, "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, @@ -159,6 +163,8 @@ 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, + "target": None, "is_wopi_supported": False, "link_reach": item_c.link_reach, "link_role": item_c.link_role, @@ -196,6 +202,8 @@ def test_api_items_search_authenticated_fulltext_query(indexer_settings): "filename": None, "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..eae241443 --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_shortcuts.py @@ -0,0 +1,240 @@ +"""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_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() + 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_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() + 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 40d83c2df..49aacb192 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,8 @@ def test_api_items_trashbin_format(settings): "size": None, "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 4c3ffa88d..8a099e0e1 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,8 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "depth": 3, "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, @@ -197,6 +199,8 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "depth": 3, "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, @@ -230,6 +234,8 @@ def test_items_api_anonymous_to_a_public_tree_structure(): "depth": 2, "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, @@ -389,6 +395,8 @@ 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, + "target": None, "link_reach": level2_1.item.link_reach, "link_role": level2_1.item.link_role, "nb_accesses": 3, @@ -437,6 +445,8 @@ 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, + "target": None, "link_reach": level3_1.item.link_reach, "link_role": level3_1.item.link_role, "nb_accesses": 4, @@ -475,6 +485,8 @@ 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, + "target": None, "link_reach": level2_2.item.link_reach, "link_role": level2_2.item.link_role, "nb_accesses": 3, @@ -508,6 +520,8 @@ 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, + "target": None, "link_reach": level1_1.item.link_reach, "link_role": level1_1.item.link_role, "nb_accesses": 2, @@ -547,6 +561,8 @@ 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, + "target": None, "link_reach": level1_2.item.link_reach, "link_role": level1_2.item.link_role, "nb_accesses": 2, @@ -586,6 +602,8 @@ 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, + "target": None, "link_reach": level1_3.item.link_reach, "link_role": level1_3.item.link_role, "nb_accesses": 2, @@ -619,6 +637,8 @@ 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, + "target": None, "link_reach": root.item.link_reach, "link_role": root.item.link_role, "nb_accesses": 1, @@ -735,6 +755,8 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "depth": 2, "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, @@ -779,6 +801,8 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "depth": 3, "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, @@ -818,6 +842,8 @@ def test_api_items_tree_authenticated_with_access_authenticated(): "depth": 3, "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, diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index b5fadc2b3..c37788012 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) @@ -101,7 +113,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 @@ -302,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, @@ -352,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, @@ -457,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, @@ -527,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, @@ -557,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, @@ -617,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, @@ -687,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, @@ -740,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, @@ -798,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, @@ -829,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, @@ -1089,7 +1116,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_restricted.py b/src/backend/core/tests/test_models_items_restricted.py new file mode 100644 index 000000000..a43ac647d --- /dev/null +++ b/src/backend/core/tests/test_models_items_restricted.py @@ -0,0 +1,512 @@ +"""Tests for restricted folder model behavior.""" + +from django.core.exceptions import ValidationError + +import pytest +from lasuite.drf.models.choices import LinkReachChoices + +from core import factories, models + +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) + 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) + + +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_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_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_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) + 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() + 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_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, 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..a6e0d6090 --- /dev/null +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -0,0 +1,227 @@ +"""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_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_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_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) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + shortcut = folder.shortcut + + grandparent.soft_delete() + + 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_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() + + 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) + + +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) + folder = folder.restrict(user) + shortcut = folder.shortcut + + folder.soft_delete() + + 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_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 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_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() + 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(): + """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} diff --git a/src/backend/core/tests/test_services_search_indexers.py b/src/backend/core/tests/test_services_search_indexers.py index 295ef5f86..ba36b85c9 100644 --- a/src/backend/core/tests/test_services_search_indexers.py +++ b/src/backend/core/tests/test_services_search_indexers.py @@ -479,8 +479,25 @@ 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() + + +@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") @@ -871,8 +888,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") 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", }, 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, 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")