nice
This commit is contained in:
parent
e1473076e2
commit
9834626cf4
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
120
backend/drive/test_quota_allocation_protection.py
Normal file
120
backend/drive/test_quota_allocation_protection.py
Normal 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)
|
||||
@ -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'])
|
||||
|
||||
@ -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}) {
|
||||
</div>}
|
||||
<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>
|
||||
<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>}
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>}
|
||||
|
||||
@ -207,9 +207,10 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
|
||||
</div>
|
||||
{child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>}
|
||||
</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}/>
|
||||
<div className="mt-3 border-t pt-3 space-y-3"><QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>
|
||||
<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>
|
||||
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">
|
||||
{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>
|
||||
}
|
||||
|
||||
|
||||
@ -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 <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="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 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>}
|
||||
</div>):<p className="py-12 text-center text-sm text-muted-foreground">Ce dossier est vide.</p>}</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>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user