♻️(backend) split abilities into one method per ability

Each ability now reads as a named rule on ItemAbilities, fixing
the Sonar S3776 complexity of the former monolithic function.
Per-action methods also sketch the vocabulary a future ABAC
engine will implement, one check per action.
This commit is contained in:
Nicolas Clerc
2026-09-04 14:35:33 +02:00
parent 1b4b756917
commit d5d49c7e8f
+154 -95
View File
@@ -1,7 +1,10 @@
"""Role-based permissions backend."""
from __future__ import annotations
from django.conf import settings
from django.db.models import Q
from django.contrib.auth.models import AnonymousUser
from django.db.models import Q, QuerySet
from lasuite.drf.models.choices import LinkReachChoices, RoleChoices
@@ -10,112 +13,168 @@ from core.permissions.backends.base import PermissionsBackend
from wopi.conversion.policy import target_extension_for
class ItemAbilities: # pylint: disable=too-many-public-methods
"""Compute the abilities of a user on an item, one method per ability."""
def __init__(self, user: models.User | AnonymousUser, item: models.Item) -> None:
self.user = user
self.item = item
# Explicitly compute anything that may hit the database, once
# The access role is based on accesses only, before any link boost
self.access_role = item.get_role(user)
self.is_deleted = bool(item.ancestors_deleted_at)
link_definition = item.computed_link_definition
link_reach = link_definition["link_reach"]
if link_reach == LinkReachChoices.PUBLIC or (
link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
# The highest of the access role and the link role, needed for a user
# with an access lower than the link role and for a user without access
self.role = RoleChoices.max(self.access_role, link_definition["link_role"])
else:
self.role = self.access_role
self.is_owner = self.access_role == RoleChoices.OWNER
self.is_owner_or_admin = self.is_owner or self.access_role == RoleChoices.ADMIN
def has_access_role(self) -> bool:
"""Return whether the user holds a role through accesses on a live item."""
# Based on accesses only so that anonymous users granted by a link
# cannot see item accesses or versions
return bool(self.access_role) and not self.is_deleted
def link_select_options(self) -> dict[str, list[str]]:
"""Return the link reach and role options selectable on the item."""
if not self.has_access_role():
return {}
return LinkReachChoices.get_select_options(**self.item.ancestors_link_definition)
def can_get(self) -> bool:
"""Return whether the user can read the item."""
return bool(self.role) and not self.is_deleted
def can_retrieve(self) -> bool:
"""Return whether the user can retrieve the item, even soft deleted."""
return self.can_get() or self.is_owner
def can_manage(self) -> bool:
"""Return whether the user can manage the item and its accesses."""
return self.is_owner_or_admin and not self.is_deleted
def can_update(self) -> bool:
"""Return whether the user can modify the item."""
return (self.is_owner_or_admin or self.role == RoleChoices.EDITOR) and not self.is_deleted
def can_create_children(self) -> bool:
"""Return whether the user can create children in the item."""
return self.can_update() and self.user.is_authenticated
def can_hard_delete(self) -> bool:
"""Return whether the user can delete the item permanently."""
if self.item.is_root:
return self.is_owner
creator_can_delete = (
self.user.is_authenticated
and self.item.creator_id == self.user.id
and self.role == RoleChoices.EDITOR
)
return self.is_owner_or_admin or creator_can_delete
def can_destroy(self) -> bool:
"""Return whether the user can remove the item."""
return self.can_hard_delete() and not self.is_deleted
def can_duplicate(self) -> bool:
"""Return whether the user can duplicate the file."""
return (
self.can_get()
and self.user.is_authenticated
and self.item.type == models.ItemTypeChoices.FILE
and self.item.upload_state == models.ItemUploadStateChoices.READY
)
def can_export(self) -> bool:
"""Return whether the user can export the folder as an archive."""
return self.can_get() and self.item.type == models.ItemTypeChoices.FOLDER
def can_convert(self) -> bool:
"""Return whether the user can convert the file to another format."""
return (
self.can_update()
and self.item.type == models.ItemTypeChoices.FILE
and self.item.upload_state
in (
models.ItemUploadStateChoices.READY,
models.ItemUploadStateChoices.ANALYZING,
)
and bool(target_extension_for(self.item.extension))
and bool(settings.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET)
)
def can_favorite(self) -> bool:
"""Return whether the user can mark the item as favorite."""
return self.can_get() and self.user.is_authenticated
def can_invite_owner(self) -> bool:
"""Return whether the user can invite another owner on the item."""
return self.is_owner and not self.is_deleted
def can_restore(self) -> bool:
"""Return whether the user can restore the item from the trash."""
return self.is_owner
def can_upload_ended(self) -> bool:
"""Return whether the user can mark an upload on the item as ended."""
return self.can_update() and self.user.is_authenticated
def as_dict(self) -> dict[str, bool | dict[str, list[str]]]:
"""Return the ability mapping exposed by the API."""
return {
"accesses_manage": self.can_manage(),
"accesses_view": self.has_access_role(),
"breadcrumb": self.can_get(),
"children_list": self.can_get(),
"children_create": self.can_create_children(),
"destroy": self.can_destroy(),
"download": self.can_get(),
"duplicate": self.can_duplicate(),
"export": self.can_export(),
"hard_delete": self.can_hard_delete(),
"favorite": self.can_favorite(),
"link_configuration": self.can_manage(),
"invite_owner": self.can_invite_owner(),
"link_select_options": self.link_select_options(),
"move": self.can_manage(),
"restore": self.can_restore(),
"retrieve": self.can_retrieve(),
"tree": self.can_get(),
"media_auth": self.can_get(),
"partial_update": self.can_update(),
"update": self.can_update(),
"upload_ended": self.can_upload_ended(),
"wopi": self.can_get(),
"convert": self.can_convert(),
}
class RolePermissionsBackend(PermissionsBackend):
"""Role-based engine inheriting roles along the item tree."""
def effective_accesses(self, item):
def effective_accesses(self, item: models.Item) -> QuerySet[models.ItemAccess]:
"""Return the accesses applying to the item, direct or inherited."""
return models.ItemAccess.objects.filter(
item__path__ancestors=item.path,
)
def roles_at(self, user, path):
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."""
return models.ItemAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
item__path__ancestors=path,
).values_list("role", flat=True)
def abilities(self, user, item): # pylint: disable=too-many-locals
def abilities(
self, user: models.User | AnonymousUser, item: models.Item
) -> dict[str, bool | dict[str, list[str]]]:
"""Compute and return abilities for a given user on the item."""
# First get the role based on specific access
role = item.get_role(user)
# Characteristics that are based only on specific access
is_owner = role == RoleChoices.OWNER
is_deleted = item.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(**item.ancestors_link_definition)
if has_access_role
else {}
)
link_definition = item.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 item.is_root
else (
is_owner_or_admin
or (
user.is_authenticated
and item.creator_id == user.pk
and role == RoleChoices.EDITOR
)
)
)
can_destroy = can_hard_delete and not is_deleted
can_duplicate = (
can_get
and user.is_authenticated
and item.type == models.ItemTypeChoices.FILE
and item.upload_state == models.ItemUploadStateChoices.READY
)
can_export = can_get and item.type == models.ItemTypeChoices.FOLDER
can_convert = (
can_update
and item.type == models.ItemTypeChoices.FILE
and item.upload_state
in (
models.ItemUploadStateChoices.READY,
models.ItemUploadStateChoices.ANALYZING,
)
and bool(target_extension_for(item.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,
}
return ItemAbilities(user, item).as_dict()