Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6980008
🔧(dev) align demo passwords with keycloak realm
kernicPanel Jun 30, 2026
2b17686
✨(backend) add is_restricted field to Item model
kernicPanel Jun 23, 2026
f9af57b
♻️(backend) extract role resolution into a permissions backend
kernicPanel Jul 6, 2026
42e5cb9
✨(backend) cut role inheritance on restricted folders
kernicPanel Jul 6, 2026
5e2bbb2
✨(backend) cut linkreach inheritance on restricted folders
kernicPanel Jun 23, 2026
f630208
♻️(backend) move abilities computation to the permissions backend
kernicPanel Jul 6, 2026
9296c1d
✨(backend) add restrict ability to get_abilities
kernicPanel Jul 6, 2026
bfc7c21
✨(backend) add activate_restriction method to Item model
kernicPanel Jun 26, 2026
cecd976
✨(backend) add deactivate_restriction method to Item model
kernicPanel Jun 26, 2026
c7bc135
✨(backend) add uproot method to Item model
kernicPanel Jun 29, 2026
7b140c7
✨(backend) extract restricted folders on soft delete
kernicPanel Jun 30, 2026
64a0984
✨(backend) expose is_restricted field in items API
kernicPanel Jun 30, 2026
88e9913
✨(backend) uproot restricted folders on DELETE by parent owner
kernicPanel Jun 30, 2026
6290c61
✨(backend) cut role inheritance in annotate_user_roles
kernicPanel Jul 1, 2026
4cec906
✨(backend) respect restriction boundaries in link configuration
kernicPanel Jul 1, 2026
ddb8730
✨(backend) respect restriction boundaries in access management
kernicPanel Jul 1, 2026
2b52158
✨(backend) exclude inaccessible items from search results
kernicPanel Jul 1, 2026
e69f5d3
✨(backend) exclude restricted content from folder export
kernicPanel Jul 1, 2026
166a1a2
✨(backend) exclude restricted content from search index ACLs
kernicPanel Jul 1, 2026
39757b8
🐛(backend) override parent() to resolve it by exact path
kernicPanel Jul 1, 2026
b851c8c
🚨(backend) refactor link validate to a single return
kernicPanel Jul 6, 2026
f97b33f
✅(backend) tighten exception tests around raising calls
kernicPanel Jul 6, 2026
0c1802d
♻️(backend) split abilities into one property per ability
kernicPanel Jul 6, 2026
47a544b
✨(backend) allow restricting a folder at creation
kernicPanel Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ and this project adheres to
- ✨(backend) allow converting a file while it is being analyzed
- ✨(frontend) add file type, contact and modification date topbar filters
- ✨(frontend) add location, file type, contact and date search filters
- ♻️(backend) route permission decisions through a swappable backend
- ✨(backend) preserve restricted folders when an ancestor is soft-deleted

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to create confusion and break things. Instead you should forbid deletion by a user if there is a restricted folder on which s.he does not have deletion rights...
Uprooting the restricted folder can be suggested but should be done explicitly... Maybe it means that owners should see restricted folders (greyed) and be able to move them out of their folders even if they have no rights on them...

- ✨(backend) add restricted access on folders to cut role and link inheritance
- ✨(backend) keep restricted folders intact when the parent owner deletes them
- ✨(backend) exclude restricted content from search results
- ✨(backend) exclude restricted content from folder export
- ✨(backend) allow restricting a folder at creation

### Fixed

Expand All @@ -21,6 +28,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

Expand Down
56 changes: 56 additions & 0 deletions docker/auth/realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 50 additions & 13 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# pylint: disable=no-name-in-module

from __future__ import annotations

import json
import logging
from datetime import timedelta
Expand All @@ -13,7 +15,7 @@
from django.utils.translation import gettext_lazy as _

from lasuite.drf.models.choices import LinkReachChoices, get_equivalent_link_definition
from rest_framework import serializers
from rest_framework import exceptions, serializers

from core import models
from core.api import utils
Expand Down Expand Up @@ -248,6 +250,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"is_restricted",
"nb_accesses",
"numchild",
"numchild_folder",
Expand Down Expand Up @@ -280,6 +283,7 @@ class Meta:
"creator",
"depth",
"is_favorite",
"is_restricted",
"link_role",
"link_reach",
"nb_accesses",
Expand Down Expand Up @@ -478,6 +482,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"is_restricted",
"nb_accesses",
"numchild",
"numchild_folder",
Expand Down Expand Up @@ -534,7 +539,17 @@ 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()
if is_restricted:
instance.activate_restriction(user)
else:
instance.deactivate_restriction()

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"))
Expand Down Expand Up @@ -581,6 +596,7 @@ class Meta:
"creator",
"depth",
"is_favorite",
"is_restricted",
"link_role",
"link_reach",
"nb_accesses",
Expand Down Expand Up @@ -691,6 +707,12 @@ def validate(self, attrs):
code="item_create_folder_title_required",
)

if attrs.get("is_restricted") and attrs["type"] != models.ItemTypeChoices.FOLDER:
raise serializers.ValidationError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this constraint should be defined on the model directly

{"is_restricted": _("Only folders can be restricted.")},
code="item_create_restricted_only_on_folders",
)

return super().validate(attrs)

def get_policy(self, item):
Expand Down Expand Up @@ -748,15 +770,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
)
Expand Down Expand Up @@ -788,12 +803,34 @@ 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.")})

# Restricted folders are independent of parent link configuration
if self.instance.is_restricted:
if link_reach == LinkReachChoices.RESTRICTED and link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
else:
self._validate_against_ancestors(link_reach, link_role)

return attrs


Expand Down
81 changes: 50 additions & 31 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

from core import enums, models
from core.entitlements import get_entitlements_backend
from core.permissions import get_permissions_backend
from core.services.item_exports import build_zip_stream, export_descendants
from core.services.sdk_relay import SDKRelayManager
from core.services.search_indexers import (
Expand Down Expand Up @@ -679,8 +680,13 @@ 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()
"""Override to implement a soft delete or uproot for restricted folders."""
abilities = instance.get_abilities(self.request.user)
# Parent owner with no access to restricted child can only displace, not delete
if abilities["destroy"] and not abilities["hard_delete"] and instance.is_restricted:
instance.uproot()
else:
instance.soft_delete()

def perform_update(self, serializer):
"""Override to check if a file is renamed in order to rename file on storage."""
Expand Down Expand Up @@ -1124,11 +1130,15 @@ def children(self, request, *args, **kwargs):
# Apply ordering only now that everything is filtered and annotated
queryset = ItemOrdering().filter_queryset(self.request, queryset, self)

# Pre-compute number of accesses
# Pre-compute number of accesses; the parent's count does not apply
# to restricted children which cut inheritance
item_nb_accesses = item.nb_accesses

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be renamed to nb_effective_accesses.
The current name is a bit confusing. I had to reread the nb_accesses method on the model to remember what past me wrote. 😅

direct_accesses_count = Coalesce(db.Count("accesses", distinct=True), 0)
queryset = queryset.annotate(
_nb_accesses=db.Value(item_nb_accesses)
+ Coalesce(db.Count("accesses", distinct=True), 0),
_nb_accesses=db.Case(
db.When(is_restricted=True, then=direct_accesses_count),
default=db.Value(item_nb_accesses) + direct_accesses_count,
),
)

# Pass ancestors' links paths mapping to the serializer as a context variable
Expand Down Expand Up @@ -1407,7 +1417,7 @@ def search(self, request, *args, **kwargs):

# Without the indexer, the "title" filtering is kept
queryset = filterset.filter_queryset(queryset)
queryset = queryset.annotate_user_roles(user)
queryset = get_permissions_backend().visible(queryset, user)
queryset = queryset.annotate_with_numchild()

page = self.paginate_queryset(queryset)
Expand Down Expand Up @@ -1466,7 +1476,7 @@ def link_configuration(self, request, *args, **kwargs):
if models.LinkReachChoices.get_priority(
item.link_reach
) >= models.LinkReachChoices.get_priority(previous_link_reach):
item.descendants().update(link_reach=None)
get_permissions_backend().propagation_scope(item).update(link_reach=None)

return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)

Expand Down Expand Up @@ -1620,7 +1630,7 @@ def export(self, request, *args, **kwargs):
"""
folder = self.get_object()

descendants = export_descendants(folder)
descendants = export_descendants(folder, user=request.user)
zip_stream = build_zip_stream(descendants)

encoded_name = quote(f"{folder.title}.zip", safe="")
Expand Down Expand Up @@ -1842,9 +1852,12 @@ def list(self, request, *args, **kwargs):
if not role:
return drf.response.Response([])

# Get all accesses from ancestors (including current item)
ancestors_qs = models.Item.objects.filter(
path__ancestors=self.item.path, ancestors_deleted_at__isnull=True
# Get all accesses from ancestors (including current item), stopping
# at the deepest restricted folder which cuts inheritance
ancestors_qs = (
get_permissions_backend()
.inheritance_scope(self.item)
.filter(ancestors_deleted_at__isnull=True)
)
accesses_qs = self.get_queryset().filter(item__in=ancestors_qs)
if role not in PRIVILEGED_ROLES:
Expand Down Expand Up @@ -1967,25 +1980,27 @@ def perform_create(self, serializer):
)

# Look for the max ancestors role of the item for the current user.
ancestor_qs = (self.item.ancestors() | models.Item.objects.filter(pk=self.item.pk)).filter(
ancestors_deleted_at__isnull=True
)
ancestors_roles = models.ItemAccess.objects.filter(
item__in=ancestor_qs, user=serializer.validated_data.get("user")
).values_list("role", flat=True)
max_ancestors_role = models.RoleChoices.max(*ancestors_roles)

if models.RoleChoices.get_priority(max_ancestors_role) >= models.RoleChoices.get_priority(
role
):
raise drf.exceptions.ValidationError(
{
"role": (
f"The role {role} you are trying to assign is lower or equal"
f" than the max ancestors role {max_ancestors_role}."
),
}
)
# Restricted folders cut inheritance: only check the item itself.
if not self.item.is_restricted:
ancestor_qs = (
self.item.ancestors() | models.Item.objects.filter(pk=self.item.pk)
).filter(ancestors_deleted_at__isnull=True)
ancestors_roles = models.ItemAccess.objects.filter(
item__in=ancestor_qs, user=serializer.validated_data.get("user")
).values_list("role", flat=True)
max_ancestors_role = models.RoleChoices.max(*ancestors_roles)

if models.RoleChoices.get_priority(
max_ancestors_role
) >= models.RoleChoices.get_priority(role):
raise drf.exceptions.ValidationError(
{
"role": (
f"The role {role} you are trying to assign is lower or equal"
f" than the max ancestors role {max_ancestors_role}."
),
}
)

access = serializer.save(item_id=self.kwargs["resource_id"])
self._syncronize_descendants_accesses(access)
Expand Down Expand Up @@ -2028,7 +2043,11 @@ def _syncronize_descendants_accesses(self, access):
Syncronize the accesses of the descendants of the item
by removing accesses with roles lower than the current user's role.
"""
descendants = self.item.descendants().filter(ancestors_deleted_at__isnull=True)
descendants = (
get_permissions_backend()
.propagation_scope(self.item)
.filter(ancestors_deleted_at__isnull=True)
)

condition_filter = db.Q()
if access.user:
Expand Down
27 changes: 27 additions & 0 deletions src/backend/core/migrations/0025_item_add_is_restricted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.14 on 2026-06-23 09:58

import django.contrib.postgres.indexes
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0024_alter_item_upload_state'),
]

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'),
),
migrations.AddIndex(
model_name='item',
index=django.contrib.postgres.indexes.GistIndex(condition=models.Q(('is_restricted', True)), fields=['path'], name='drive_item_restricted_path_ix'),
),
]
Loading
Loading