Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
77c6124
🔧(dev) align demo passwords with keycloak realm
kernicPanel Jun 30, 2026
54a07fc
♻️(backend) extract role resolution into a permissions backend
kernicPanel Jul 6, 2026
e5dc7db
♻️(backend) move abilities computation to the permissions backend
kernicPanel Jul 6, 2026
99ccd58
♻️(backend) split abilities into one property per ability
kernicPanel Jul 6, 2026
214cabb
🚨(backend) refactor link validate to a single return
kernicPanel Jul 6, 2026
e1d8846
✅(backend) tighten exception tests around raising calls
kernicPanel Jul 6, 2026
7d55533
🐛(backend) override parent() to resolve it by exact path
kernicPanel Jul 1, 2026
82f3b2c
✨(backend) add is_restricted field to Item model
kernicPanel Jul 24, 2026
ef01070
✨(backend) add shortcut item type targeting another item
kernicPanel Jul 24, 2026
29cefa3
✨(backend) add restrict ability with activation and deactivation states
kernicPanel Jul 27, 2026
4697db3
✨(backend) activate restriction by moving the folder to the tree root
kernicPanel Jul 27, 2026
c42b3d3
✨(backend) deactivate restriction by reattaching at the shortcut
kernicPanel Jul 27, 2026
f76987d
✨(backend) normalize explicit accesses on restriction deactivation
kernicPanel Jul 27, 2026
f54f395
✨(backend) normalize explicit link reach on restriction deactivation
kernicPanel Jul 27, 2026
d7c2f16
✨(backend) expose is_restricted field in items API
kernicPanel Jul 27, 2026
144ff71
✨(backend) expose shortcut targets in the items API
kernicPanel Jul 27, 2026
6091744
✨(backend) hide reachable restricted roots from the top-level listing
kernicPanel Jul 27, 2026
faf0580
✨(backend) detach restricted folders by deleting their shortcut
kernicPanel Jul 28, 2026
6c267c2
✨(backend) detach subtree shortcuts when an ancestor is trashed
kernicPanel Jul 28, 2026
c9f7b2c
✨(backend) detach the shortcut when a restricted folder is trashed
kernicPanel Jul 28, 2026
955de79
✨(backend) exclude shortcuts from search, export and indexing
kernicPanel Jul 28, 2026
2c57264
✨(backend) allow restricting a folder at creation
kernicPanel Jul 28, 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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ and this project adheres to

## [Unreleased]

### Added

- ✨(backend) add restricted access on folders, detached behind a shortcut
- ✨(backend) allow restricting a folder at creation

### Changed

- ♻️(backend) route permission decisions through a swappable backend

### Fixed

- 🐛(backend) resolve the direct parent by exact path after a move

## [v0.21.1] - 2026-08-21

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

# 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 _

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 @@ -510,6 +557,7 @@ class Meta:
"creator",
"depth",
"is_favorite",
"is_restricted",
"nb_accesses",
"link_role",
"link_reach",
Expand All @@ -534,7 +582,7 @@ 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 title uniqueness."""
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 +625,7 @@ class Meta:
"creator",
"depth",
"is_favorite",
"is_restricted",
"link_role",
"link_reach",
"nb_accesses",
Expand Down Expand Up @@ -687,6 +736,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 +805,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 +838,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