Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ and this project adheres to

## [Unreleased]

### 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",

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.

paige?

"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
27 changes: 16 additions & 11 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 Down Expand Up @@ -744,15 +746,8 @@ class Meta:
"link_reach",
]

def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")

if not link_reach:
raise serializers.ValidationError({"link_reach": _("This field is required.")})

# Get available options based on ancestors' link definition
def _validate_against_ancestors(self, link_reach: str, link_role: str) -> None:
"""Validate the link definition against the options allowed by ancestors."""
available_options = LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
Expand Down Expand Up @@ -784,12 +779,22 @@ def validate(self, attrs):
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
f"Link role '{link_role}' is not allowed for link reach "
f"'{link_reach}'. Allowed roles: {allowed_roles_str}"
)
}
)

def validate(self, attrs: dict) -> dict:
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")

if not link_reach:
raise serializers.ValidationError({"link_reach": _("This field is required.")})

self._validate_against_ancestors(link_reach, link_role)

return attrs


Expand Down
14 changes: 13 additions & 1 deletion src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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."""

Expand Down
22 changes: 22 additions & 0 deletions src/backend/core/migrations/0029_item_add_is_restricted.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
32 changes: 32 additions & 0 deletions src/backend/core/migrations/0030_item_add_shortcut_target.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Loading