This commit is contained in:
Louis Mazin 2026-10-14 17:57:24 +02:00
parent e1473076e2
commit 9834626cf4
7 changed files with 165 additions and 13 deletions

View File

@ -59,6 +59,25 @@ def has_space_right(user, space, right):
return right in space_rights(user, space) 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): def item_rights(user, item):
rights = space_rights(user, item.space) rights = space_rights(user, item.space)
if rights: if rights:

View File

@ -7,7 +7,7 @@ from django.db import transaction
from django.http import Http404 from django.http import Http404
from .models import DriveSpace, SpaceGrant, DriveItem from .models import DriveSpace, SpaceGrant, DriveItem
from .quotas import PRIVATE_SPACE_QUOTA_BYTES 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): def is_admin(user):
@ -159,6 +159,7 @@ def space_info(space, user):
'can_invite': can_manage_space(user, space), 'can_invite': can_manage_space(user, space),
'permissions': sorted(space_rights(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': 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)), '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, 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True,
**quota_state(space), **quota_state(space),

View File

@ -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)

View File

@ -20,7 +20,7 @@ from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadS
ItemGrant, PublicItemLink, ArchivedDriveAccount) ItemGrant, PublicItemLink, ArchivedDriveAccount)
from .services import error, valid_name 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 .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, from .quotas import (parse_quota, locked_quota_tree,
assert_new_child_quota, assert_updated_quota) 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) write=child.can_write_parent,invite=child.can_invite)
except ValueError as exc:return error(str(exc),403) except ValueError as exc:return error(str(exc),403)
if 'quota_bytes' in request.data: 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']) try:_update_quota(child, request.data['quota_bytes'])
except ValueError as ex:return error(str(ex)) except ValueError as ex:return error(str(ex))
if changes:child.save(update_fields=changes) if changes:child.save(update_fields=changes)
@ -157,14 +159,14 @@ def update_member(request,space_id):
@api_view(['PATCH']) @api_view(['PATCH'])
def update_space_quota(request, space_id): 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(): with transaction.atomic():
try:space=DriveSpace.objects.select_related('parent','user').get(pk=space_id) try:space=DriveSpace.objects.select_related('parent','user').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id: if space.private_owner_id:
return error('Le quota des espaces privés est fixé à 50 Go.',403) return error('Le quota des espaces privés est fixé à 50 Go.',403)
if not has_space_right(request.user,space,'manage_space'): if not can_change_space_quota(request.user,space):
return error('Modification du quota interdite.',403) 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.') if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.')
try:_update_quota(space,request.data['quota_bytes']) try:_update_quota(space,request.data['quota_bytes'])
except ValueError as ex:return error(str(ex)) 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: if 'parent_space' in request.data:
moved = _relocate_shared_space(request.user, space, request.data['parent_space']) moved = _relocate_shared_space(request.user, space, request.data['parent_space'])
if 'quota_bytes' in request.data: 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']) _update_quota(space,request.data['quota_bytes'])
if 'display_name' in request.data: if 'display_name' in request.data:
title=valid_name(request.data['display_name']) title=valid_name(request.data['display_name'])

View File

@ -31,7 +31,9 @@ export default function SpaceSettingsDialog({controller: c}) {
setBusy(true);setLocalError('') setBusy(true);setLocalError('')
try { try {
if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.') 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) 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)}) const result=manage ? await drive.editSpace(current.id,data) : await drive.createSpace({...data,parent_space:Number(parent)})
await loadSpaces() await loadSpaces()
@ -70,7 +72,8 @@ export default function SpaceSettingsDialog({controller: c}) {
</div>} </div>}
<div className="space-y-2"><Label htmlFor="space-name">Nom affiché</Label> <div className="space-y-2"><Label htmlFor="space-name">Nom affiché</Label>
<Input id="space-name" value={name} onChange={e=>setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" /></div> <Input id="space-name" value={name} onChange={e=>setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" /></div>
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} /> {(!manage || current?.can_edit_quota) &&
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} />}
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>} {localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
<DialogFooter className="gap-2 sm:justify-between"> <DialogFooter className="gap-2 sm:justify-between">
{manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>} {manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>}

View File

@ -207,9 +207,10 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
</div> </div>
{child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>} {child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>}
</div> </div>
return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({permissions:rights,...(quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><PermissionsEditor value={rights} onChange={setRights} maxRights={maxRights?.permissions}/> return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({permissions:rights,...(!child.can_edit_quota || quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><PermissionsEditor value={rights} onChange={setRights} maxRights={maxRights?.permissions}/>
<div className="mt-3 border-t pt-3 space-y-3"><QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/> <div className="mt-3 border-t pt-3 space-y-3">
<p className="text-xs text-muted-foreground">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)}</p> {child.can_edit_quota && <QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>}
<p className="text-xs text-muted-foreground">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)}</p>
</div></div> </div></div>
} }

View File

@ -36,7 +36,8 @@ export default function PublicSharePage({token}) {
finally{setLoading(false)} finally{setLoading(false)}
},[token,parent]) },[token,parent])
useEffect(()=>{load()},[load]) 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 previous=()=>setParent(data?.breadcrumb?.length>1?data.breadcrumb.at(-2).id:null)
const items=data?.items || [] const items=data?.items || []
return <div className="min-h-dvh bg-muted/25 px-4 py-5 sm:py-12"> return <div className="min-h-dvh bg-muted/25 px-4 py-5 sm:py-12">
@ -56,11 +57,14 @@ export default function PublicSharePage({token}) {
<div className="space-y-1.5">{items.length ? items.map(item=><div key={item.id} className="group flex items-center gap-3 rounded-xl border border-transparent p-3 transition-colors hover:border-border hover:bg-muted/45"> <div className="space-y-1.5">{items.length ? items.map(item=><div key={item.id} className="group flex items-center gap-3 rounded-xl border border-transparent p-3 transition-colors hover:border-border hover:bg-muted/45">
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">{item.is_folder?<Folder className="size-6"/>:typeOf(item)==='image'?<Image className="size-5"/>:typeOf(item)==='pdf'?<FileText className="size-5"/>:<File className="size-5"/>}</div> <div className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">{item.is_folder?<Folder className="size-6"/>:typeOf(item)==='image'?<Image className="size-5"/>:typeOf(item)==='pdf'?<FileText className="size-5"/>:<File className="size-5"/>}</div>
<button type="button" onClick={()=>item.is_folder?setParent(item.id):setSelected(item)} className="min-w-0 flex-1 text-left"><span className="block truncate text-sm font-medium">{item.name}</span><span className="text-xs text-muted-foreground">{item.is_folder?'Dossier':fmtSize(item.size)}</span></button> <button type="button" onClick={()=>item.is_folder?setParent(item.id):setSelected(item)} className="min-w-0 flex-1 text-left"><span className="block truncate text-sm font-medium">{item.name}</span><span className="text-xs text-muted-foreground">{item.is_folder?'Dossier':fmtSize(item.size)}</span></button>
<Button variant="ghost" size="icon-sm" title={item.is_folder?'Ouvrir':'Visualiser'} onClick={()=>item.is_folder?setParent(item.id):setSelected(item)}>{item.is_folder?<FolderOpen className="size-4"/>:<Eye className="size-4"/>}</Button> <Button variant="outline" size="sm" onClick={()=>item.is_folder?setParent(item.id):setSelected(item)}>{item.is_folder?<FolderOpen className="size-4"/>:<Eye className="size-4"/>} Ouvrir</Button>
{!item.is_folder && <a className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-muted" aria-label={`Télécharger ${item.name}`} href={publicFileUrl(token,item.id)}><Download className="size-4"/></a>} {!item.is_folder && <a className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-muted" aria-label={`Télécharger ${item.name}`} href={publicFileUrl(token,item.id)}><Download className="size-4"/></a>}
</div>):<p className="py-12 text-center text-sm text-muted-foreground">Ce dossier est vide.</p>}</div> </div>):<p className="py-12 text-center text-sm text-muted-foreground">Ce dossier est vide.</p>}</div>
</div>} </div>}
{!data.root.is_folder && <div className="flex justify-end px-5 py-3"><a className="inline-flex h-10 items-center gap-2 rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-90" href={publicFileUrl(token,data.root.id)}><Download className="size-4"/> Télécharger</a></div>} {!data.root.is_folder && <div className="flex flex-wrap items-center justify-end gap-3 px-5 py-5 sm:px-8">
<Button onClick={()=>setSelected(data.root)} className="gap-2"><Eye className="size-4"/> Ouvrir</Button>
<a className="inline-flex h-10 items-center gap-2 rounded-xl border bg-background px-4 text-sm font-medium hover:bg-muted" href={publicFileUrl(token,data.root.id)}><Download className="size-4"/> Télécharger</a>
</div>}
</>} </>}
</main> </main>
</div> </div>