🐛(backend) allow creating accesses when privileged by heritage

We took the opportunity of this bug to refactor serializers and
permissions as advised one day by @qbey: no permission checks in
serializers.
This commit is contained in:
Samuel Paccoud - DINUM
2025-05-06 09:41:16 +02:00
committed by Anthony LC
parent d12c637dad
commit 433cead0ac
6 changed files with 192 additions and 134 deletions

View File

@@ -19,6 +19,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -356,7 +357,7 @@ class DocumentViewSet(
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
permissions.DocumentPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
@@ -842,7 +843,7 @@ class DocumentViewSet(
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound from excpt
raise drf.exceptions.NotFound() from excpt
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
@@ -902,7 +903,10 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["post"],
permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission],
permission_classes=[
permissions.IsAuthenticated,
permissions.DocumentPermission,
],
url_path="duplicate",
)
@transaction.atomic
@@ -1473,25 +1477,32 @@ class DocumentAccessViewSet(
"""
lookup_field = "pk"
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAccess.objects.select_related("user").all()
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.DocumentAccess.objects.select_related("user", "document").only(
"id",
"created_at",
"role",
"team",
"user__id",
"user__short_name",
"user__full_name",
"user__email",
"user__language",
"document__id",
"document__path",
"document__depth",
)
resource_field_name = "document"
def __init__(self, *args, **kwargs):
"""Initialize the viewset and define default value for contextual document."""
super().__init__(*args, **kwargs)
self.document = None
def initial(self, request, *args, **kwargs):
"""Retrieve self.document with annotated user roles."""
super().initial(request, *args, **kwargs)
@cached_property
def document(self):
"""Get related document from resource ID in url and annotate user roles."""
try:
self.document = models.Document.objects.annotate_user_roles(
self.request.user
).get(pk=self.kwargs["resource_id"])
return models.Document.objects.annotate_user_roles(self.request.user).get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as excpt:
raise Http404() from excpt
raise drf.exceptions.NotFound() from excpt
def get_serializer_class(self):
"""Use light serializer for unprivileged users."""
@@ -1579,8 +1590,24 @@ class DocumentAccessViewSet(
return drf.response.Response(serialized_data)
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
"""
Actually create the new document access:
- Ensures the `document_id` is explicitly set from the URL
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
- Sends an invitation email to the newly added user after saving the access.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.document.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a document can assign other users as owners."
)
access = serializer.save(document_id=self.kwargs["resource_id"])
access.document.send_invitation_email(
access.user.email,
@@ -1626,7 +1653,7 @@ class TemplateViewSet(
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
@@ -1717,11 +1744,19 @@ class TemplateAccessViewSet(
"""
lookup_field = "pk"
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
@@ -1739,6 +1774,25 @@ class TemplateAccessViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -1771,7 +1825,7 @@ class InvitationViewset(
pagination_class = Pagination
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
queryset = (
models.Invitation.objects.all()