From 9834626cf4a04617a37ab5df5f442bd2617f2285 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 17:57:24 +0200 Subject: [PATCH] nice --- backend/drive/permissions.py | 19 +++ backend/drive/spaces.py | 3 +- .../drive/test_quota_allocation_protection.py | 120 ++++++++++++++++++ backend/drive/views_spaces.py | 12 +- .../drive/dialogs/SpaceSettingsDialog.jsx | 7 +- frontend/src/pages/MembersPage.jsx | 7 +- frontend/src/pages/PublicSharePage.jsx | 10 +- 7 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 backend/drive/test_quota_allocation_protection.py diff --git a/backend/drive/permissions.py b/backend/drive/permissions.py index d5638ad..df6acf1 100644 --- a/backend/drive/permissions.py +++ b/backend/drive/permissions.py @@ -59,6 +59,25 @@ def has_space_right(user, space, right): return right in space_rights(user, space) +def can_change_space_quota(user, space): + """The allocated quota is decided by the administrator, not the invitee. + + A non-admin may size only a shared subspace that they themselves created, + provided they still have the space-management permission. Having + ``manage_space`` on an assigned/shared space must never allow increasing + the storage allocation set by its creator/administrator. + """ + if space is None or space.private_owner_id or not user.is_authenticated or not user.is_active: + return False + if _is_admin(user): + return True + return (space.parent_id is not None + and space.user_id is None + and space.managed_by_id == user.pk + and not SpaceGrant.objects.filter(user=user, space=space).exists() + and has_space_right(user, space, 'manage_space')) + + def item_rights(user, item): rights = space_rights(user, item.space) if rights: diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py index b42bb3b..bd51ad8 100644 --- a/backend/drive/spaces.py +++ b/backend/drive/spaces.py @@ -7,7 +7,7 @@ from django.db import transaction from django.http import Http404 from .models import DriveSpace, SpaceGrant, DriveItem from .quotas import PRIVATE_SPACE_QUOTA_BYTES -from .permissions import has_space_right, space_rights +from .permissions import has_space_right, space_rights, can_change_space_quota def is_admin(user): @@ -159,6 +159,7 @@ def space_info(space, user): 'can_invite': can_manage_space(user, space), 'permissions': sorted(space_rights(user, space)), 'can_edit': not space.private_owner_id and has_space_right(user, space, 'manage_space'), + 'can_edit_quota': can_change_space_quota(user, space), 'can_delete': not space.private_owner_id and has_space_right(user, space, 'manage_space') and (space.parent_id is not None or is_admin(user)), 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True, **quota_state(space), diff --git a/backend/drive/test_quota_allocation_protection.py b/backend/drive/test_quota_allocation_protection.py new file mode 100644 index 0000000..08079ca --- /dev/null +++ b/backend/drive/test_quota_allocation_protection.py @@ -0,0 +1,120 @@ +"""Regressions: an invitee cannot enlarge their assigned storage allocation. + +Run with: USE_SQLITE=true python manage.py test drive.test_quota_allocation_protection +""" +import tempfile +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from .models import SpaceGrant +from .spaces import _make_numbered_space, ensure_space, space_info + + +class AllocatedQuotaProtectionTests(TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + settings = override_settings(DRIVE_STORAGE_ROOT=Path(directory.name), + FILE_UPLOAD_TEMP_DIR=str(Path(directory.name) / '.uploads')) + settings.enable() + self.addCleanup(settings.disable) + + User = get_user_model() + self.admin = User.objects.create_superuser('quota-admin', 'admin@example.com', 'TestPassword123!') + self.member = User.objects.create_user('quota-member', password='TestPassword123!') + self.client = APIClient() + self.root = ensure_space(self.admin) + self.root.quota_bytes = 100 * 1024**3 + self.root.save(update_fields=['quota_bytes']) + self.allocated = _make_numbered_space( + self.root, managed_by=self.admin, display_name='Espace attribué', quota_bytes=50 * 1024**3) + SpaceGrant.objects.create( + user=self.member, space=self.allocated, + permissions=['view', 'download', 'manage_space', 'manage_access', 'create_spaces']) + self.child_by_admin = _make_numbered_space( + self.allocated, managed_by=self.admin, display_name='Sous-espace administrateur', + quota_bytes=20 * 1024**3) + self.child_by_member = _make_numbered_space( + self.allocated, managed_by=self.member, display_name='Sous-espace personnel', + quota_bytes=20 * 1024**3) + self.client.force_authenticate(self.member) + + def test_member_cannot_change_allocation_through_quota_endpoint(self): + response = self.client.patch( + f'/api/drive/spaces/{self.allocated.pk}/quota/', + {'quota_bytes': 80 * 1024**3}, format='json') + self.assertEqual(response.status_code, 403, response.data) + self.allocated.refresh_from_db() + self.assertEqual(self.allocated.quota_bytes, 50 * 1024**3) + + def test_member_can_rename_allocated_space_but_not_change_quota(self): + response = self.client.patch( + f'/api/drive/spaces/{self.allocated.pk}/', + {'display_name': 'Nom autorisé'}, format='json') + self.assertEqual(response.status_code, 200, response.data) + forbidden = self.client.patch( + f'/api/drive/spaces/{self.allocated.pk}/', + {'display_name': 'Nom non enregistré', 'quota_bytes': None}, format='json') + self.assertEqual(forbidden.status_code, 403, forbidden.data) + self.allocated.refresh_from_db() + self.assertEqual(self.allocated.display_name, 'Nom autorisé') + self.assertEqual(self.allocated.quota_bytes, 50 * 1024**3) + self.assertFalse(space_info(self.allocated, self.member)['can_edit_quota']) + + def test_member_cannot_change_other_users_allocations_using_manage_access(self): + response = self.client.patch( + f'/api/drive/spaces/members/{self.child_by_admin.pk}/', + {'quota_bytes': 40 * 1024**3}, format='json') + self.assertEqual(response.status_code, 403, response.data) + self.child_by_admin.refresh_from_db() + self.assertEqual(self.child_by_admin.quota_bytes, 20 * 1024**3) + + def test_member_can_adjust_the_subspace_they_created(self): + self.assertTrue(space_info(self.child_by_member, self.member)['can_edit_quota']) + response = self.client.patch( + f'/api/drive/spaces/{self.child_by_member.pk}/', + {'quota_bytes': 30 * 1024**3}, format='json') + self.assertEqual(response.status_code, 200, response.data) + self.child_by_member.refresh_from_db() + self.assertEqual(self.child_by_member.quota_bytes, 30 * 1024**3) + + def test_admin_still_controls_allocated_quota(self): + self.client.force_authenticate(self.admin) + response = self.client.patch( + f'/api/drive/spaces/{self.allocated.pk}/quota/', + {'quota_bytes': 75 * 1024**3}, format='json') + self.assertEqual(response.status_code, 200, response.data) + self.allocated.refresh_from_db() + self.assertEqual(self.allocated.quota_bytes, 75 * 1024**3) + self.assertTrue(space_info(self.allocated, self.admin)['can_edit_quota']) + + def test_legacy_assigned_owner_must_not_enlarge_allocation(self): + User = get_user_model() + legacy = User.objects.create_user('quota-legacy', password='TestPassword123!') + allocated = _make_numbered_space( + self.root, user=legacy, display_name='Ancien espace attribué', + quota_bytes=40 * 1024**3) + self.client.force_authenticate(legacy) + response = self.client.patch( + f'/api/drive/spaces/{allocated.pk}/', + {'quota_bytes': 70 * 1024**3}, format='json') + self.assertEqual(response.status_code, 403, response.data) + allocated.refresh_from_db() + self.assertEqual(allocated.quota_bytes, 40 * 1024**3) + + def test_grant_makes_a_created_space_an_assigned_space_not_resizable(self): + # Even a creator loses quota control if this space has become their + # assigned access point through an explicit share grant. + SpaceGrant.objects.create( + user=self.member, space=self.child_by_member, + permissions=['view', 'download', 'manage_space']) + self.assertFalse(space_info(self.child_by_member, self.member)['can_edit_quota']) + response = self.client.patch( + f'/api/drive/spaces/{self.child_by_member.pk}/', + {'quota_bytes': 45 * 1024**3}, format='json') + self.assertEqual(response.status_code, 403, response.data) + self.child_by_member.refresh_from_db() + self.assertEqual(self.child_by_member.quota_bytes, 20 * 1024**3) diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index aaf4bf5..ebeac16 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -20,7 +20,7 @@ from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadS ItemGrant, PublicItemLink, ArchivedDriveAccount) from .services import error, valid_name from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_write_space, can_manage_space -from .permissions import RIGHTS, PRESETS, normalized, legacy_rights, space_rights, has_space_right, assert_delegation +from .permissions import RIGHTS, PRESETS, normalized, legacy_rights, space_rights, has_space_right, assert_delegation, can_change_space_quota from .quotas import (parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota) @@ -143,6 +143,8 @@ def update_member(request,space_id): write=child.can_write_parent,invite=child.can_invite) except ValueError as exc:return error(str(exc),403) if 'quota_bytes' in request.data: + if not can_change_space_quota(request.user, child): + return error('Seul un administrateur peut modifier le quota attribué à cet espace.',403) try:_update_quota(child, request.data['quota_bytes']) except ValueError as ex:return error(str(ex)) if changes:child.save(update_fields=changes) @@ -157,14 +159,14 @@ def update_member(request,space_id): @api_view(['PATCH']) def update_space_quota(request, space_id): - """Administrators may cap any space; delegated inviters manage their direct children.""" + """Admins can adjust allocations; others only their own created subspaces.""" with transaction.atomic(): try:space=DriveSpace.objects.select_related('parent','user').get(pk=space_id) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) if space.private_owner_id: return error('Le quota des espaces privés est fixé à 50 Go.',403) - if not has_space_right(request.user,space,'manage_space'): - return error('Modification du quota interdite.',403) + if not can_change_space_quota(request.user,space): + return error('Seul un administrateur peut modifier le quota attribué à cet espace.',403) if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.') try:_update_quota(space,request.data['quota_bytes']) except ValueError as ex:return error(str(ex)) @@ -517,6 +519,8 @@ def edit_shared_space(request,space_id): if 'parent_space' in request.data: moved = _relocate_shared_space(request.user, space, request.data['parent_space']) if 'quota_bytes' in request.data: + if not can_change_space_quota(request.user, space): + raise PermissionError('Seul un administrateur peut modifier le quota attribué à cet espace.') _update_quota(space,request.data['quota_bytes']) if 'display_name' in request.data: title=valid_name(request.data['display_name']) diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index d28b03b..5f82a8e 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -31,7 +31,9 @@ export default function SpaceSettingsDialog({controller: c}) { setBusy(true);setLocalError('') try { if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.') - const data={display_name:name.trim(),quota_bytes:toQuota(quota)} + const data={display_name:name.trim()} + // Managing an assigned/shared space must never resubmit its quota. + if (!manage || current?.can_edit_quota) data.quota_bytes=toQuota(quota) if (manage && canMove && parent && Number(parent) !== current.parent_id) data.parent_space = Number(parent) const result=manage ? await drive.editSpace(current.id,data) : await drive.createSpace({...data,parent_space:Number(parent)}) await loadSpaces() @@ -70,7 +72,8 @@ export default function SpaceSettingsDialog({controller: c}) { }
setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" />
- + {(!manage || current?.can_edit_quota) && + } {localError &&

{localError}

} {manage && current?.can_invite && } diff --git a/frontend/src/pages/MembersPage.jsx b/frontend/src/pages/MembersPage.jsx index b4c6679..b3d12ee 100644 --- a/frontend/src/pages/MembersPage.jsx +++ b/frontend/src/pages/MembersPage.jsx @@ -207,9 +207,10 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) { {child.can_invite && } - return
{child.display_name}
{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}
-
-

Utilisé : {fmtSize(child.used_bytes || 0)}{child.reserved_bytes > 0 ? ` · Transferts réservés : ${fmtSize(child.reserved_bytes)}` : ''} · Disponible : {child.available_bytes == null ? 'Sans limite' : fmtSize(child.available_bytes)}

+ return
{child.display_name}
{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}
+
+ {child.can_edit_quota && } +

Limite : {child.quota_bytes == null ? 'Sans limite' : fmtSize(child.quota_bytes)} · Utilisé : {fmtSize(child.used_bytes || 0)}{child.reserved_bytes > 0 ? ` · Transferts réservés : ${fmtSize(child.reserved_bytes)}` : ''} · Disponible : {child.available_bytes == null ? 'Sans limite' : fmtSize(child.available_bytes)}

} diff --git a/frontend/src/pages/PublicSharePage.jsx b/frontend/src/pages/PublicSharePage.jsx index 9e591ad..2e0a26b 100644 --- a/frontend/src/pages/PublicSharePage.jsx +++ b/frontend/src/pages/PublicSharePage.jsx @@ -36,7 +36,8 @@ export default function PublicSharePage({token}) { finally{setLoading(false)} },[token,parent]) useEffect(()=>{load()},[load]) - useEffect(()=>{if(data?.root && !data.root.is_folder)setSelected(data.root)},[data?.root?.id]) + // A public file link first shows its details; preview starts only on "Ouvrir". + useEffect(()=>{setSelected(null)},[token]) const previous=()=>setParent(data?.breadcrumb?.length>1?data.breadcrumb.at(-2).id:null) const items=data?.items || [] return
@@ -56,11 +57,14 @@ export default function PublicSharePage({token}) {
{items.length ? items.map(item=>
{item.is_folder?:typeOf(item)==='image'?:typeOf(item)==='pdf'?:}
- + {!item.is_folder && }
):

Ce dossier est vide.

}
} - {!data.root.is_folder && } + {!data.root.is_folder &&
+ + Télécharger +
} }