diff --git a/backend/drive/permissions.py b/backend/drive/permissions.py index 7dbdb15..d5638ad 100644 --- a/backend/drive/permissions.py +++ b/backend/drive/permissions.py @@ -37,13 +37,15 @@ def space_rights(user, space): if space.private_owner_id: # Never expose someone else's private contents, including to admins. return set(RIGHTS) if space.private_owner_id == user.pk else set() - if _is_admin(user) or space.managed_by_id == user.pk: - return set(RIGHTS) - if space.user_id == user.pk: + if _is_admin(user): return set(RIGHTS) + # A grant can also explicitly limit an assigned owner or manager, whose + # historical roles otherwise implied full access. grant = SpaceGrant.objects.filter(space=space, user=user).first() if grant: return set(grant.permissions) if grant.permissions is not None else set(legacy_rights(write=grant.can_write, invite=grant.can_invite)) + if space.managed_by_id == user.pk or space.user_id == user.pk: + return set(RIGHTS) if space.parent_id and space.can_view_parent and space.user_id == user.pk: return set(legacy_rights(write=space.can_write_parent, invite=space.can_invite)) # Historical assigned-space access (parent) for legacy sub-users. diff --git a/backend/drive/test_space_reparent_and_access.py b/backend/drive/test_space_reparent_and_access.py new file mode 100644 index 0000000..c5cda31 --- /dev/null +++ b/backend/drive/test_space_reparent_and_access.py @@ -0,0 +1,154 @@ +"""Run: USE_SQLITE=true python manage.py test drive.test_space_reparent_and_access""" +import tempfile +import uuid +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 DriveItem, DriveSpace, SpaceGrant +from .permissions import space_rights +from .spaces import _make_numbered_space, ensure_space, ensure_private_space +from .quotas import occupancy + + +class SpaceReparentingTests(TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.storage = Path(tmp.name) + override = override_settings(DRIVE_STORAGE_ROOT=self.storage, + FILE_UPLOAD_TEMP_DIR=str(self.storage / '.uploads')) + override.enable() + self.addCleanup(override.disable) + User = get_user_model() + self.admin = User.objects.create_superuser('space-admin', 'admin@example.com', 'password-for-tests') + self.member = User.objects.create_user('space-member', password='password-for-tests') + self.client = APIClient() + self.client.force_authenticate(self.admin) + self.root = ensure_space(self.admin) + gb = 1024 ** 3 + self.root.quota_bytes = 100 * gb + self.root.save(update_fields=['quota_bytes']) + self.a = _make_numbered_space(self.root, managed_by=self.admin, + display_name='A', quota_bytes=60 * gb) + self.b = _make_numbered_space(self.root, managed_by=self.admin, + display_name='B', quota_bytes=50 * gb) + self.child = _make_numbered_space(self.a, managed_by=self.admin, + display_name='Child', quota_bytes=30 * gb) + self.nested = _make_numbered_space(self.child, managed_by=self.admin, + display_name='Nested', quota_bytes=10 * gb) + self.old_path = self.child.relative_path + self.file = DriveItem.objects.create(owner=self.admin, space=self.nested, + name='example.pdf', size=15, + storage_key=str(uuid.uuid4()), + storage_location=f'{self.nested.relative_path}/example.pdf') + filepath = self.storage / self.file.storage_location + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_bytes(b'important bytes') + + def test_move_subtree_preserves_files_and_nested_spaces(self): + res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', + {'parent_space': self.b.pk}, format='json') + self.assertEqual(res.status_code, 200, res.data) + self.child.refresh_from_db() + self.nested.refresh_from_db() + self.file.refresh_from_db() + expected = f'{self.b.relative_path}/{self.child.directory_name}' + self.assertEqual(self.child.parent_id, self.b.pk) + self.assertEqual(self.child.relative_path, expected) + self.assertEqual(self.nested.relative_path, f'{expected}/{self.nested.directory_name}') + self.assertEqual(self.file.storage_location, + f'{self.nested.relative_path}/example.pdf') + self.assertEqual((self.storage / self.file.storage_location).read_bytes(), b'important bytes') + self.assertFalse((self.storage / self.old_path).exists()) + self.assertEqual(occupancy(self.b)[0], 15) + self.assertEqual(occupancy(self.a)[0], 0) + + def test_move_refused_if_it_would_create_cycle(self): + res = self.client.patch(f'/api/drive/spaces/{self.a.pk}/', + {'parent_space': self.nested.pk}, format='json') + self.assertEqual(res.status_code, 400, res.data) + self.a.refresh_from_db() + self.assertEqual(self.a.parent_id, self.root.pk) + self.assertTrue((self.storage / self.old_path).exists()) + + def test_move_rolls_back_when_new_quota_is_insufficient(self): + res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', + {'parent_space': self.b.pk, 'quota_bytes': 0}, format='json') + self.assertEqual(res.status_code, 400, res.data) + self.child.refresh_from_db() + self.file.refresh_from_db() + self.assertEqual(self.child.parent_id, self.a.pk) + self.assertEqual(self.child.relative_path, self.old_path) + self.assertTrue((self.storage / self.file.storage_location).is_file()) + + def test_manage_space_without_create_right_on_target_cannot_move(self): + SpaceGrant.objects.create(user=self.member, space=self.child, + permissions=['view','manage_space']) + SpaceGrant.objects.create(user=self.member, space=self.b, + permissions=['view']) + self.client.force_authenticate(self.member) + res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', + {'parent_space': self.b.pk}, format='json') + self.assertEqual(res.status_code, 403, res.data) + self.child.refresh_from_db() + self.assertEqual(self.child.parent_id, self.a.pk) + + def test_private_space_keeps_its_separate_physical_storage(self): + SpaceGrant.objects.create(user=self.member, space=self.child, + permissions=['view','download']) + private = ensure_private_space(self.member) + self.assertTrue(private.relative_path.startswith('privates/')) + res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', + {'parent_space': self.b.pk}, format='json') + self.assertEqual(res.status_code, 200, res.data) + private.refresh_from_db() + self.assertEqual(private.parent_id, self.child.pk) + self.assertTrue((self.storage/private.relative_path).is_dir()) + + def test_assigned_space_move_does_not_grant_parent_permissions(self): + assigned = _make_numbered_space(self.a, user=self.member, display_name='Assigned', + quota_bytes=10 * 1024**3, can_view_parent=True, + can_write_parent=True, can_invite=True, + parent_permissions=['view','download','add','manage_access']) + self.assertIn('manage_access', space_rights(self.member,self.a)) + res = self.client.patch(f'/api/drive/spaces/{assigned.pk}/', + {'parent_space':self.b.pk}, format='json') + self.assertEqual(res.status_code,200,res.data) + assigned.refresh_from_db() + self.assertFalse(assigned.can_view_parent) + self.assertFalse(assigned.can_invite) + self.assertFalse(space_rights(self.member,self.b)) + + def test_move_is_blocked_by_occupied_destination_quota(self): + huge = 50 * 1024**3 + DriveItem.objects.create(owner=self.admin,space=self.b,name='full.bin',size=huge, + storage_key=str(uuid.uuid4()),storage_location='legacy-file') + res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/', + {'parent_space':self.b.pk},format='json') + self.assertEqual(res.status_code,400,res.data) + self.child.refresh_from_db() + self.assertEqual(self.child.parent_id,self.a.pk) + self.assertTrue((self.storage/self.old_path).is_dir()) + + def test_admin_edits_direct_and_assigned_access_without_creating_duplicates(self): + assigned = _make_numbered_space(self.root, user=self.member, + display_name='Assigned', quota_bytes=10 * 1024**3) + listing = self.client.get(f'/api/drive/admin/users/{self.member.pk}/access/') + self.assertEqual(listing.status_code, 200, listing.data) + self.assertEqual(sum(a['space_id'] == assigned.pk for a in listing.data['accesses']), 1) + url = f'/api/drive/admin/users/{self.member.pk}/access/owner/{assigned.pk}/' + saved = self.client.patch(url, {'permissions':['view','download']}, format='json') + self.assertEqual(saved.status_code, 200, saved.data) + self.assertEqual(space_rights(self.member, assigned), {'view','download'}) + denied = self.client.patch(url, {'permissions':['modify']}, format='json') + self.assertEqual(denied.status_code, 400, denied.data) + listing = self.client.get(f'/api/drive/admin/users/{self.member.pk}/access/') + self.assertEqual(sum(a['space_id'] == assigned.pk for a in listing.data['accesses']), 1) + self.assertEqual(next(access['permissions'] for access in listing.data['accesses'] + if access['space_id'] == assigned.pk), ['view','download']) + revoked = self.client.delete(url) + self.assertEqual(revoked.status_code, 204) + self.assertFalse(SpaceGrant.objects.filter(user=self.member,space=assigned).exists()) diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index abb38d9..aaf4bf5 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError +from django.db import models from django.utils import timezone from rest_framework.decorators import api_view, permission_classes, throttle_classes from rest_framework.permissions import AllowAny @@ -396,6 +397,108 @@ def create_shared_space(request): return Response(space_info(space,request.user),status=201) +def _relocate_shared_space(user, space, target_parent_id): + """Change an entire shared-space subtree's physical and logical parent. + + Called with a DB transaction and all DriveSpace rows locked. Database + changes happen before the single same-filesystem directory rename; caller + reverses the rename if the transaction fails. + """ + from .quotas import ancestry, occupancy, subtree_space_ids + from .storage_layout import disk_path + + if space.private_owner_id or space.parent_id is None: + raise ValueError('L’espace racine et les espaces privés ne peuvent pas changer de parent.') + try: + target = DriveSpace.objects.get(pk=int(target_parent_id), private_owner__isnull=True) + except (TypeError, ValueError, DriveSpace.DoesNotExist): + raise ValueError('Espace parent introuvable.') from None + if target.pk == space.parent_id: + return None # No permission required to retain the existing parent. + if not has_space_right(user, space, 'manage_space') or not has_space_right(user, target, 'create_spaces'): + raise PermissionError('Tu dois pouvoir gérer cet espace et créer des espaces dans le nouveau parent.') + descendants = subtree_space_ids(space) + if target.pk in descendants: + raise ValueError('Impossible de déplacer un espace dans lui-même ou dans un de ses sous-espaces.') + if target.private_owner_id: + raise ValueError('Un espace privé ne peut pas être parent.') + + old_prefix = space.relative_path + new_prefix = '/'.join(filter(None, [target.relative_path, space.directory_name])) + if not old_prefix or not space.directory_name or len(new_prefix) > 700: + raise ValueError('Déplacement impossible pour cet espace.') + + # All the actual files and pending uploads of this subtree must fit in + # each NEW ancestor. Common ancestors already include that usage. + from .quotas import ancestry, occupancy + subtree_used, subtree_pending = occupancy(space) + former_ancestors = {node.pk for node in ancestry(space)} + for ancestor in ancestry(target): + if ancestor.pk in former_ancestors or ancestor.quota_bytes is None: + continue + used, pending = occupancy(ancestor) + if used + pending + subtree_used + subtree_pending > ancestor.quota_bytes: + raise ValueError('Le nouvel espace parent n’a pas assez de capacité disponible.') + caps = [node.quota_bytes for node in ancestry(target) if node.quota_bytes is not None] + max_allowed = min(caps) if caps else None + if max_allowed is not None: + if DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True).filter( + models.Q(quota_bytes__isnull=True) | models.Q(quota_bytes__gt=max_allowed) + ).exists(): + raise ValueError('Le quota d’un sous-espace dépasse celui du nouveau parent.') + + # Refuse to move mid-upload: it can change the path during a file write. + if UploadSession.objects.filter(space_id__in=descendants).exists(): + raise ValueError('Termine ou annule les transferts en cours avant de changer le parent.') + if DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=False).exclude( + relative_path__startswith='privates/').exists(): + raise ValueError('Déplace d’abord les anciens espaces privés vers storage/privates/.') + + source = disk_path(old_prefix) + destination = disk_path(new_prefix) + if not source.is_dir() or source.is_symlink(): + raise ValueError('Le dossier de l’espace est introuvable ou invalide.') + if destination.exists() or destination.is_symlink(): + raise ValueError('Un dossier existe déjà à cet emplacement.') + if not destination.parent.is_dir(): + raise ValueError('Le répertoire du nouveau parent est introuvable.') + + # Include all nested shared spaces, but NEVER touch storage/privates/. + affected = list(DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True)) + prefix = old_prefix + '/' + for node in affected: + if node.pk != space.pk and not node.relative_path.startswith(prefix): + raise ValueError('Incohérence entre l’arborescence et le stockage physique.') + updated = new_prefix + node.relative_path[len(old_prefix):] + if len(updated) > 700: + raise ValueError('Hiérarchie trop profonde pour ce déplacement.') + node.relative_path = updated + # A rollback will restore all of these SQL updates, including the item paths. + for node in affected: + node.save(update_fields=['relative_path']) + space.parent = target + # Legacy assigned spaces could implicitly pass permissions to their parent. + # Moving them must not silently grant those rights on a DIFFERENT parent. + if space.user_id is not None: + space.can_view_parent = False + space.can_write_parent = False + space.can_invite = False + space.parent_permissions = None + space.save(update_fields=['parent','can_view_parent','can_write_parent','can_invite','parent_permissions']) + else: + space.save(update_fields=['parent']) + # Modern readable-storage keys follow the physical directory; legacy + # UUID locations outside this subtree remain untouched. + items = DriveItem.objects.filter(space_id__in=[node.pk for node in affected], + storage_location__startswith=prefix) + # Include files stored directly under old_prefix as well. + for item in items.iterator(chunk_size=500): + item.storage_location = new_prefix + item.storage_location[len(old_prefix):] + item.save(update_fields=['storage_location']) + source.rename(destination) # Atomic on this filesystem. + return source, destination + + @api_view(['PATCH','DELETE']) def edit_shared_space(request,space_id): try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id) @@ -404,16 +507,33 @@ def edit_shared_space(request,space_id): return error('Modification interdite.',403) if request.method == 'DELETE': return delete_shared_space(request, space_id) + moved = None try: with transaction.atomic(): - if 'display_name' in request.data: - title=valid_name(request.data['display_name']) - if len(title)>120:return error('Nom limité à 120 caractères.') - space.display_name=title - space.save(update_fields=['display_name']) + # Prevent concurrent moves/creation or quota alterations from + # observing an incomplete hierarchy. + list(DriveSpace.objects.select_for_update().order_by('pk')) + space = DriveSpace.objects.select_related('parent','managed_by').get(pk=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: _update_quota(space,request.data['quota_bytes']) - except ValueError as exc:return error(str(exc)) + if 'display_name' in request.data: + title=valid_name(request.data['display_name']) + if len(title)>120:raise ValueError('Nom limité à 120 caractères.') + space.display_name=title + space.save(update_fields=['display_name']) + except Exception as exc: + if moved is not None: + source, destination = moved + try: + destination.rename(source) + except OSError: + _logger.exception('ALERTE: impossible de restaurer un espace après un échec SQL: %s', source) + return error('Déplacement interrompu : intervention manuelle nécessaire.',500) + if isinstance(exc,PermissionError):return error(str(exc),403) + if isinstance(exc,(ValueError,OSError)):return error(str(exc),409 if isinstance(exc,OSError) else 400) + raise return Response(space_info(space,request.user)) @@ -589,16 +709,22 @@ def admin_user_access(request, user_id): if is_admin(target): return error('Impossible de modifier les accès d’un administrateur.', 403) accesses = [] + role_spaces = set() + for kind, role_filter in [('owner', {'user':target}), ('manager', {'managed_by':target})]: + for space in DriveSpace.objects.filter(private_owner__isnull=True, **role_filter): + if space.pk in role_spaces: + continue + role_spaces.add(space.pk) + accesses.append({'kind':kind, 'space_id':space.pk, + 'space_name':space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, space)]}) for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'): - accesses.append({'kind':'grant', 'space_id':grant.space_id, 'space_name':grant.space.display_name}) - for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True): - accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name}) - for space in DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True): - accesses.append({'kind':'manager', 'space_id':space.pk, 'space_name':space.display_name}) + if grant.space_id not in role_spaces: + accesses.append({'kind':'grant', 'space_id':grant.space_id, + 'space_name':grant.space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, grant.space)]}) return Response({'accesses': accesses}) -@api_view(['DELETE']) +@api_view(['DELETE','PATCH']) def admin_revoke_user_access(request, user_id, kind, space_id): """Revoke a space membership, legacy assignment, or managed-space grant. @@ -618,15 +744,35 @@ def admin_revoke_user_access(request, user_id, kind, space_id): if is_admin(target): return error('Impossible de modifier les accès d’un administrateur.', 403) qs = DriveSpace.objects.select_for_update().filter(pk=space_id, private_owner__isnull=True) + if request.method == 'PATCH': + space = qs.first() + if space is None: + return error('Espace introuvable.',404) + if kind == 'owner' and space.user_id != target.pk or kind == 'manager' and space.managed_by_id != target.pk: + return error('Accès introuvable.',404) + if kind == 'grant' and not SpaceGrant.objects.filter(space=space,user=target).exists(): + return error('Accès introuvable.',404) + try: + rights = normalized(request.data.get('permissions')) + except ValueError as exc: + return error(str(exc)) + grant, _ = SpaceGrant.objects.select_for_update().get_or_create(user=target,space=space) + grant.permissions=rights + grant.can_write='add' in rights and 'modify' in rights + grant.can_invite='manage_access' in rights + grant.save(update_fields=['permissions','can_write','can_invite']) + return Response({'space_id':space.pk,'permissions':rights}) if kind == 'grant': deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete() if not deleted:return error('Accès introuvable.', 404) elif kind == 'owner': if not qs.filter(user=target).update(user=None): return error('Accès introuvable.', 404) + SpaceGrant.objects.filter(space_id=space_id,user=target).delete() else: if not qs.filter(managed_by=target).update(managed_by=None): return error('Accès introuvable.', 404) + SpaceGrant.objects.filter(space_id=space_id,user=target).delete() return Response(status=204) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 5de0a46..21648f1 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -76,6 +76,7 @@ export const drive={ adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}), adminUserAccess:id=>api.get(`/drive/admin/users/${id}/access/`).then(r=>r.data.accesses), revokeUserAccess:(id,kind,spaceId)=>api.delete(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`), + updateUserAccess:(id,kind,spaceId,permissions)=>api.patch(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`,{permissions}).then(r=>r.data), stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data), folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data), diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index 487ae38..d28b03b 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -16,6 +16,8 @@ export default function SpaceSettingsDialog({controller: c}) { const [quota,setQuota] = useState('') const [parent,setParent] = useState('') const [busy,setBusy] = useState(false) + const eligibleParents = spaces.filter(space => !space.is_private && space.id !== current?.id && space.permissions?.includes('create_spaces') && !isDescendant(space, current, spaces)) + const canMove = manage && !!current?.parent_id && eligibleParents.some(space => space.id !== current.parent_id) const [localError,setLocalError] = useState('') useEffect(() => { setName(manage ? current?.display_name || '' : '') @@ -30,9 +32,11 @@ export default function SpaceSettingsDialog({controller: c}) { try { if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.') const data={display_name:name.trim(),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() if (!manage) switchSpace(result.id) + if (manage && data.parent_space) await c.reloadStats() setNote(manage ? 'Espace modifié.' : 'Espace créé.') setSpaceDialog(null) } catch(exception) {setLocalError(exception?.response ? message(exception) : exception.message || message(exception))} @@ -56,6 +60,14 @@ export default function SpaceSettingsDialog({controller: c}) { className="h-10 w-full rounded-md border bg-background px-3 text-sm"> {managed.map(space=>)} } + {canMove &&
200 résultats affichés. Précise la recherche.
} } + +function AccessPermissionsRow({access,busy,save,revoke}) { + const [permissions,setPermissions] = useState(access.permissions || ['view','download']) + useEffect(()=>setPermissions(access.permissions || ['view','download']),[access.permissions]) + return