Skip to content

Commit b868b6d

Browse files
sampaccoudPanchoutNathan
authored andcommitted
♻️(backend) optimize refactoring access abilities and fix inheritance
The latest refactoring in a445278 kept some factorizations that are not legit anymore after the refactoring. It is also cleaner to not make serializer choice in the list view if the reason for this choice is related to something else b/c other views would then use the wrong serializer and that would be a security leak. This commit also fixes a bug in the access rights inheritance: if a user is allowed to see accesses on a document, he should see all acesses related to ancestors, even the ancestors that he can not read. This is because the access that was granted on all ancestors also apply on the current document... so it must be displayed. Lastly, we optimize database queries because the number of accesses we fetch is going up with multi-pages and we were generating a lot of useless queries.
1 parent 9b29a0b commit b868b6d

File tree

5 files changed

+313
-207
lines changed

5 files changed

+313
-207
lines changed

src/backend/core/api/serializers.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,10 @@ class Meta:
3232
class UserLightSerializer(UserSerializer):
3333
"""Serialize users with limited fields."""
3434

35-
id = serializers.SerializerMethodField(read_only=True)
36-
email = serializers.SerializerMethodField(read_only=True)
37-
38-
def get_id(self, _user):
39-
"""Return always None. Here to have the same fields than in UserSerializer."""
40-
return None
41-
42-
def get_email(self, _user):
43-
"""Return always None. Here to have the same fields than in UserSerializer."""
44-
return None
45-
4635
class Meta:
4736
model = models.User
48-
fields = ["id", "email", "full_name", "short_name"]
49-
read_only_fields = ["id", "email", "full_name", "short_name"]
37+
fields = ["full_name", "short_name"]
38+
read_only_fields = ["full_name", "short_name"]
5039

5140

5241
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -59,11 +48,11 @@ def update(self, instance, validated_data):
5948
validated_data.pop("user", None)
6049
return super().update(instance, validated_data)
6150

62-
def get_abilities(self, access) -> dict:
51+
def get_abilities(self, instance) -> dict:
6352
"""Return abilities of the logged-in user on the instance."""
6453
request = self.context.get("request")
6554
if request:
66-
return access.get_abilities(request.user)
55+
return instance.get_abilities(request.user)
6756
return {}
6857

6958
def validate(self, attrs):
@@ -77,7 +66,6 @@ def validate(self, attrs):
7766
# Update
7867
if self.instance:
7968
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
80-
8169
if role and role not in can_set_role_to:
8270
message = (
8371
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
@@ -140,19 +128,41 @@ class DocumentAccessSerializer(BaseAccessSerializer):
140128
class Meta:
141129
model = models.DocumentAccess
142130
resource_field_name = "document"
143-
fields = ["id", "document_id", "user", "user_id", "team", "role", "abilities"]
131+
fields = [
132+
"id",
133+
"document_id",
134+
"user",
135+
"user_id",
136+
"team",
137+
"role",
138+
"abilities",
139+
]
144140
read_only_fields = ["id", "document_id", "abilities"]
145141

146142

147-
class DocumentAccessLightSerializer(BaseAccessSerializer):
143+
class DocumentAccessLightSerializer(DocumentAccessSerializer):
148144
"""Serialize document accesses with limited fields."""
149145

150146
user = UserLightSerializer(read_only=True)
151147

152148
class Meta:
153149
model = models.DocumentAccess
154-
fields = ["id", "user", "team", "role", "abilities"]
155-
read_only_fields = ["id", "team", "role", "abilities"]
150+
resource_field_name = "document"
151+
fields = [
152+
"id",
153+
"document_id",
154+
"user",
155+
"team",
156+
"role",
157+
"abilities",
158+
]
159+
read_only_fields = [
160+
"id",
161+
"document_id",
162+
"team",
163+
"role",
164+
"abilities",
165+
]
156166

157167

158168
class TemplateAccessSerializer(BaseAccessSerializer):

src/backend/core/api/viewsets.py

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import logging
66
import uuid
7+
from collections import defaultdict
78
from urllib.parse import unquote, urlencode, urlparse
89

910
from django.conf import settings
@@ -1421,49 +1422,88 @@ class DocumentAccessViewSet(
14211422
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
14221423
queryset = models.DocumentAccess.objects.select_related("user").all()
14231424
resource_field_name = "document"
1424-
serializer_class = serializers.DocumentAccessSerializer
14251425

1426-
def list(self, request, *args, **kwargs):
1427-
"""Return accesses for the current document with filters and annotations."""
1428-
user = self.request.user
1426+
def __init__(self, *args, **kwargs):
1427+
"""Initialize the viewset and define default value for contextual document."""
1428+
super().__init__(*args, **kwargs)
1429+
self.document = None
14291430

1430-
try:
1431-
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
1432-
except models.Document.DoesNotExist:
1433-
return drf.response.Response([])
1431+
def initial(self, request, *args, **kwargs):
1432+
"""Retrieve self.document with annotated user roles."""
1433+
super().initial(request, *args, **kwargs)
14341434

1435-
role = document.get_role(user)
1436-
if role is None:
1437-
return drf.response.Response([])
1435+
try:
1436+
self.document = models.Document.objects.annotate_user_roles(
1437+
self.request.user
1438+
).get(pk=self.kwargs["resource_id"])
1439+
except models.Document.DoesNotExist as excpt:
1440+
raise Http404() from excpt
14381441

1439-
ancestors = (
1440-
(document.get_ancestors() | models.Document.objects.filter(pk=document.pk))
1441-
.filter(ancestors_deleted_at__isnull=True)
1442-
.order_by("path")
1442+
def get_serializer_class(self):
1443+
"""Use light serializer for unprivileged users."""
1444+
return (
1445+
serializers.DocumentAccessSerializer
1446+
if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES
1447+
else serializers.DocumentAccessLightSerializer
14431448
)
1444-
highest_readable = ancestors.readable_per_se(user).only("depth").first()
14451449

1446-
if highest_readable is None:
1450+
def list(self, request, *args, **kwargs):
1451+
"""Return accesses for the current document with filters and annotations."""
1452+
user = request.user
1453+
1454+
role = self.document.get_role(user)
1455+
if not role:
14471456
return drf.response.Response([])
14481457

1449-
queryset = self.get_queryset()
1450-
queryset = queryset.filter(
1451-
document__in=ancestors.filter(depth__gte=highest_readable.depth)
1452-
)
1458+
ancestors = (
1459+
self.document.get_ancestors()
1460+
| models.Document.objects.filter(pk=self.document.pk)
1461+
).filter(ancestors_deleted_at__isnull=True)
14531462

1454-
is_privileged = role in choices.PRIVILEGED_ROLES
1455-
if is_privileged:
1456-
serializer_class = serializers.DocumentAccessSerializer
1457-
else:
1458-
# Return only the document's privileged accesses
1463+
queryset = self.get_queryset().filter(document__in=ancestors)
1464+
1465+
if role not in choices.PRIVILEGED_ROLES:
14591466
queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES)
1460-
serializer_class = serializers.DocumentAccessLightSerializer
14611467

1462-
queryset = queryset.distinct()
1463-
serializer = serializer_class(
1464-
queryset, many=True, context=self.get_serializer_context()
1468+
accesses = list(
1469+
queryset.annotate(document_path=db.F("document__path")).order_by(
1470+
"document_path"
1471+
)
14651472
)
1466-
return drf.response.Response(serializer.data)
1473+
1474+
# Annotate more information on roles
1475+
path_to_ancestors_roles = defaultdict(list)
1476+
path_to_role = defaultdict(lambda: None)
1477+
for access in accesses:
1478+
if access.user_id == user.id or access.team in user.teams:
1479+
parent_path = access.document_path[: -models.Document.steplen]
1480+
if parent_path:
1481+
path_to_ancestors_roles[access.document_path].extend(
1482+
path_to_ancestors_roles[parent_path]
1483+
)
1484+
path_to_ancestors_roles[access.document_path].append(
1485+
path_to_role[parent_path]
1486+
)
1487+
else:
1488+
path_to_ancestors_roles[access.document_path] = []
1489+
1490+
path_to_role[access.document_path] = choices.RoleChoices.max(
1491+
path_to_role[access.document_path], access.role
1492+
)
1493+
1494+
# serialize and return the response
1495+
context = self.get_serializer_context()
1496+
serializer_class = self.get_serializer_class()
1497+
serialized_data = []
1498+
for access in accesses:
1499+
access.set_user_roles_tuple(
1500+
choices.RoleChoices.max(*path_to_ancestors_roles[access.document_path]),
1501+
path_to_role.get(access.document_path),
1502+
)
1503+
serializer = serializer_class(access, context=context)
1504+
serialized_data.append(serializer.data)
1505+
1506+
return drf.response.Response(serialized_data)
14671507

14681508
def perform_create(self, serializer):
14691509
"""Add a new access to the document and send an email to the new added user."""

0 commit comments

Comments
 (0)