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

## [Unreleased]

### Changed

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

## [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
100 changes: 5 additions & 95 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -1197,9 +1197,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
Expand Down Expand Up @@ -1239,10 +1237,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)
Comment on lines 1272 to 1277

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

role_at would work here?

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.

Yes but it will not take the advantage of the cache present in self.user_roles


Expand Down Expand Up @@ -1322,93 +1317,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)
Comment thread
kernicPanel marked this conversation as resolved.

def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
Expand Down
5 changes: 5 additions & 0 deletions src/backend/core/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Item permissions backend utilities."""

from core.permissions.factory import get_permissions_backend

__all__ = ["get_permissions_backend"]
1 change: 1 addition & 0 deletions src/backend/core/permissions/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Item permissions backends."""
38 changes: 38 additions & 0 deletions src/backend/core/permissions/backends/base.py
Original file line number Diff line number Diff line change
@@ -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:

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.

It would have been nice to move it in the first commit creating the backend since the get_abilities method was already existing.

"""Compute and return abilities for a given user on the item."""

Comment on lines +28 to +31

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.

It would have been nice to move it in the first commit creating the backend since they were already existing.

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))
Loading