mirror of
https://github.com/suitenumerique/drive.git
synced 2026-10-01 05:55:20 +02:00
♻️(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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user