Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to
- ✨(backend) make the upload ACL configurable to support GCS based storages
- ✨(frontend) show the messages widget button on the homepage
- ✨(frontend) open the messages widget from the help menu
- ✨(backend) add an item batch share endpoint gated by ALLOW_SHARE_IMPORT_FILE
- ✨(frontend) share an item with contacts imported from a file

### Changed

Expand Down
1 change: 1 addition & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This document lists all configurable environment variables for the Drive applica
|---------------------|-------------|---------------|
| `ALLOWED_HOSTS` | List of allowed hosts for the application (used in Production) | `[]` |
| `ALLOW_LOGOUT_GET_METHOD` | Allow logout via GET method | `True` |
| `ALLOW_SHARE_IMPORT_FILE` | Enable batch sharing of an item from an imported contacts file | `False` |
| `API_USERS_LIST_LIMIT` | Maximum number of users returned in API user list | `5` |
| `API_USERS_LIST_THROTTLE_RATE_BURST` | Burst throttle rate for user list API | `30/minute` |
| `API_USERS_LIST_THROTTLE_RATE_SUSTAINED` | Sustained throttle rate for user list API | `180/hour` |
Expand Down
2 changes: 2 additions & 0 deletions env.d/development/common.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ FRONTEND_HELP_MENU_CONFIG={"documentationUrl": "https://docs.numerique.gouv.fr/d
WOPI_CLIENTS="collabora"
# Report uploads as safe synchronously so files are ready instantly in e2e.
MALWARE_DETECTION_BACKEND=lasuite.malware_detection.backends.dummy.DummyBackend
# Enable the share modal contacts file import tested by share-import.spec.ts
ALLOW_SHARE_IMPORT_FILE=True
1 change: 1 addition & 0 deletions src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
"batch_share": {"POST": "accesses_manage"},
}


Expand Down
20 changes: 20 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,26 @@ class MoveItemSerializer(serializers.Serializer):
target_item_id = serializers.UUIDField(required=False)


BATCH_SHARE_MAX_ROWS = 100 # Keep in sync with the ui-kit share import modal max rows


class BatchShareRowSerializer(serializers.Serializer):
"""One row of a batch share payload: a contact email and the role to grant."""

email = serializers.EmailField()
role = serializers.ChoiceField(choices=models.RoleChoices.choices)

def validate_email(self, value):
"""Normalize emails to lower case like invitations do."""
return value.lower()


class BatchShareSerializer(serializers.Serializer):
"""Validate the payload of the item batch-share action."""

rows = BatchShareRowSerializer(many=True, allow_empty=False, max_length=BATCH_SHARE_MAX_ROWS)


class SDKRelayEventSerializer(serializers.Serializer):
"""Serializer for SDK relay events."""

Expand Down
114 changes: 86 additions & 28 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN,
)
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer
from lasuite.drf.models.choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
Expand All @@ -47,6 +47,10 @@

from core import enums, models
from core.entitlements import get_entitlements_backend
from core.services.accesses import (
batch_share_process_rows,
synchronize_descendants_accesses,
)
from core.services.item_exports import build_zip_stream, export_descendants
from core.services.sdk_relay import SDKRelayManager
from core.services.search_indexers import (
Expand Down Expand Up @@ -1488,6 +1492,84 @@ def link_configuration(self, request, *args, **kwargs):

return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)

@extend_schema(
request=serializers.BatchShareSerializer,
responses={
200: inline_serializer(
name="BatchShareResponse",
fields={
"accesses_created": drf.serializers.IntegerField(),
"invitations_created": drf.serializers.IntegerField(),
"skipped": drf.serializers.ListField(child=drf.serializers.DictField()),
},
)
},
)
@drf.decorators.action(detail=True, methods=["post"], url_path="batch-share")
def batch_share(self, request, *args, **kwargs):
"""
Share an item with a list of contacts in a single request.

Emails matching an existing user get an access, unknown emails get an
invitation. All rows are validated before any database write so a
rejected batch never creates a partial share state.
"""
if not settings.ALLOW_SHARE_IMPORT_FILE:
raise drf.exceptions.PermissionDenied(
"Batch sharing from an imported file is not enabled."
)

item = self.get_object()

serializer = serializers.BatchShareSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

# Deduplicate rows by email, keeping the first occurrence
rows = {}
for row in serializer.validated_data["rows"]:
rows.setdefault(row["email"], row["role"])

# A user cannot grant a role higher than their own. This also enforces
# that only owners can assign the owner role.
user_role_priority = models.RoleChoices.get_priority(item.get_role(request.user))
for role in rows.values():
if models.RoleChoices.get_priority(role) > user_role_priority:
raise drf.exceptions.PermissionDenied(
f"You cannot grant the role {role} which is higher than your own role."
)

created_accesses, created_invitations, skipped = batch_share_process_rows(
item, request.user, rows
)

for email, role in created_accesses + created_invitations:
item.send_invitation_email(
email,
role,
request.user,
request.user.language or settings.LANGUAGE_CODE,
)

posthog_capture(
"item_batch_share",
request.user,
{
"accesses_created": len(created_accesses),
"invitations_created": len(created_invitations),
"skipped": len(skipped),
},
item=item,
)

return drf.response.Response(
{
"accesses_created": len(created_accesses),
"invitations_created": len(created_invitations),
"skipped": skipped,
},
status=drf.status.HTTP_200_OK,
)

@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Expand Down Expand Up @@ -1954,7 +2036,7 @@ def update(self, request, *args, **kwargs):

access = serializer.save()

self._syncronize_descendants_accesses(access)
synchronize_descendants_accesses(self.item, access)

if access.role != old_role:
posthog_capture(
Expand Down Expand Up @@ -2015,7 +2097,7 @@ def perform_create(self, serializer):
)

access = serializer.save(item_id=self.kwargs["resource_id"])
self._syncronize_descendants_accesses(access)
synchronize_descendants_accesses(self.item, access)
if access.user:
access.item.send_invitation_email(
access.user.email,
Expand Down Expand Up @@ -2050,31 +2132,6 @@ def perform_destroy(self, instance):
item=item,
)

def _syncronize_descendants_accesses(self, access):
"""
Syncronize the accesses of the descendants of the item
by removing accesses with roles lower than the current user's role.
"""
descendants = self.item.descendants().filter(ancestors_deleted_at__isnull=True)

condition_filter = db.Q()
if access.user:
condition_filter |= db.Q(user=access.user)
if access.team:
condition_filter |= db.Q(team=access.team)

role_priority = models.RoleChoices.get_priority(access.role)

lower_roles = [
role
for role in models.RoleChoices.values
if models.RoleChoices.get_priority(role) <= role_priority
]

models.ItemAccess.objects.filter(
condition_filter, item__in=descendants, role__in=lower_roles
).delete()


class InvitationViewset(
drf.mixins.CreateModelMixin,
Expand Down Expand Up @@ -2270,6 +2327,7 @@ def get(self, request):
Return a dictionary of public settings.
"""
array_settings = [
"ALLOW_SHARE_IMPORT_FILE",
"AWS_S3_UPLOAD_ACL",
"CRISP_WEBSITE_ID",
"DATA_UPLOAD_MAX_MEMORY_SIZE",
Expand Down
96 changes: 96 additions & 0 deletions src/backend/core/services/accesses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Service for sharing items with registered users and inviting contacts."""

from django.db import models as db
from django.db import transaction
from django.db.models.functions import Lower

from core import models


def batch_share_process_rows(item, issuer, rows):
"""
Create the accesses and invitations for the given {email: role} mapping.

Emails matching an existing user get an access unless they already hold an
equal or higher role on the item or one of its ancestors, other emails get
an invitation unless one already exists. Return the created shares and the
skipped emails.
"""
users_by_email = {}
users_queryset = models.User.objects.annotate(email_lower=Lower("email")).filter(
email_lower__in=rows.keys()
)
for user in users_queryset:
users_by_email.setdefault(user.email.lower(), user)

# Compute the max role each matched user already holds on the item or one
# of its ancestors, mirroring the single access creation checks. Users with
# an explicit access on the item itself are always skipped as the access is
# unique per user and item.
ancestor_qs = (item.ancestors() | models.Item.objects.filter(pk=item.pk)).filter(
ancestors_deleted_at__isnull=True
)
max_role_by_user_id = {}
users_with_explicit_access = set()
existing_accesses = models.ItemAccess.objects.filter(
item__in=ancestor_qs, user__in=users_by_email.values()
).values_list("user_id", "item_id", "role")
for user_id, item_id, role in existing_accesses:
max_role_by_user_id[user_id] = models.RoleChoices.max(
max_role_by_user_id.get(user_id), role
)
if item_id == item.pk:
users_with_explicit_access.add(user_id)

already_invited = set(
item.invitations.filter(email__in=rows.keys()).values_list("email", flat=True)
)

skipped = []
created_accesses = []
created_invitations = []
with transaction.atomic():
for email, role in rows.items():
if user := users_by_email.get(email):
max_ancestors_role = max_role_by_user_id.get(user.id)
if user.id in users_with_explicit_access or models.RoleChoices.get_priority(
max_ancestors_role
) >= models.RoleChoices.get_priority(role):
skipped.append({"email": email, "reason": "already_shared"})
continue
access = models.ItemAccess.objects.create(item=item, user=user, role=role)
synchronize_descendants_accesses(item, access)
created_accesses.append((email, role))
elif email in already_invited:
skipped.append({"email": email, "reason": "already_invited"})
else:
models.Invitation.objects.create(item=item, email=email, role=role, issuer=issuer)
created_invitations.append((email, role))

return created_accesses, created_invitations, skipped


def synchronize_descendants_accesses(item, access):
"""
Syncronize the accesses of the descendants of the item
by removing accesses with roles lower than the current user's role.
"""
descendants = item.descendants().filter(ancestors_deleted_at__isnull=True)

condition_filter = db.Q()
if access.user:
condition_filter |= db.Q(user=access.user)
if access.team:
condition_filter |= db.Q(team=access.team)

role_priority = models.RoleChoices.get_priority(access.role)

lower_roles = [
role
for role in models.RoleChoices.values
if models.RoleChoices.get_priority(role) <= role_priority
]

models.ItemAccess.objects.filter(
condition_filter, item__in=descendants, role__in=lower_roles
).delete()
Loading
Loading