Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6d6b665
🔧(dev) align demo passwords with keycloak realm
kernicPanel Jun 30, 2026
1271f78
♻️(backend) extract role resolution into a permissions backend
kernicPanel Jul 6, 2026
32b4c69
♻️(backend) move abilities computation to the permissions backend
kernicPanel Jul 6, 2026
afe8f5b
♻️(backend) split abilities into one property per ability
kernicPanel Jul 6, 2026
d4d6b50
🚨(backend) refactor link validate to a single return
kernicPanel Jul 6, 2026
76529de
✅(backend) tighten exception tests around raising calls
kernicPanel Jul 6, 2026
ee1f4ba
🐛(backend) override parent() to resolve it by exact path
kernicPanel Jul 1, 2026
2186e62
✨(backend) add is_restricted field to Item model
kernicPanel Jul 24, 2026
2e688c4
✨(backend) add shortcut item type targeting another item
kernicPanel Jul 24, 2026
776005e
✨(backend) add restrict ability with activation and deactivation states
kernicPanel Jul 27, 2026
6d67164
✨(backend) activate restriction by moving the folder to the tree root
kernicPanel Jul 27, 2026
dd55508
✨(backend) deactivate restriction by reattaching at the shortcut
kernicPanel Jul 27, 2026
a65e03f
✨(backend) normalize explicit accesses on restriction deactivation
kernicPanel Jul 27, 2026
b3f7c9c
✨(backend) normalize explicit link reach on restriction deactivation
kernicPanel Jul 27, 2026
a07f857
✨(backend) expose is_restricted field in items API
kernicPanel Jul 27, 2026
02e905a
✨(backend) expose shortcut targets in the items API
kernicPanel Jul 27, 2026
07c4ba8
✨(backend) hide reachable restricted roots from the top-level listing
kernicPanel Jul 27, 2026
d4c69a0
✨(backend) detach restricted folders by deleting their shortcut
kernicPanel Jul 28, 2026
97db93b
✨(backend) detach subtree shortcuts when an ancestor is trashed
kernicPanel Jul 28, 2026
161cb0e
✨(backend) detach the shortcut when a restricted folder is trashed
kernicPanel Jul 28, 2026
1220ebe
✨(backend) exclude shortcuts from search, export and indexing
kernicPanel Jul 28, 2026
ccb9fc1
✨(backend) allow restricting a folder at creation
kernicPanel Jul 28, 2026
f3f9c90
fixup! ♻️(backend) extract role resolution into a permissions backend
kernicPanel Aug 4, 2026
02b7dd6
fixup! ✨(backend) add shortcut item type targeting another item
kernicPanel Aug 4, 2026
f6f9402
fixup! ✨(backend) detach subtree shortcuts when an ancestor is trashed
kernicPanel Aug 4, 2026
9e6a1a4
fixup! ✨(backend) detach the shortcut when a restricted folder is tra…
kernicPanel Aug 4, 2026
3c85a85
fixup! ✨(backend) detach the shortcut when a restricted folder is tra…
kernicPanel Aug 4, 2026
f005e71
fixup! 🔧(dev) align demo passwords with keycloak realm
kernicPanel Aug 6, 2026
c01e659
fixup! ✨(backend) add is_restricted field to Item model
kernicPanel Aug 13, 2026
47721ed
fixup! ✨(backend) add shortcut item type targeting another item
kernicPanel Aug 13, 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

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
101 changes: 88 additions & 13 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@

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

from __future__ import annotations

import json
import logging
from datetime import timedelta
from os.path import splitext
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
Expand Down Expand Up @@ -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."""

Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -577,6 +636,7 @@ class Meta:
"creator",
"depth",
"is_favorite",
"is_restricted",
"link_role",
"link_reach",
"nb_accesses",
Expand Down Expand Up @@ -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(

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 @@ -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
)
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading