Skip to content

Commit 9786f07

Browse files
fregataajopemachine
authored andcommitted
feat(BA-5765): add RBAC-enforced VFolder purge mutation (#11165)
1 parent 093af71 commit 9786f07

6 files changed

Lines changed: 41 additions & 44 deletions

File tree

changes/11165.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add RBAC-enforced VFolder purge v2 mutation with SingleEntityActionProcessor.

src/ai/backend/manager/api/adapters/vfolder.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,8 @@ async def restore(self, vfolder_id: UUID) -> RestoreVFolderPayload:
405405
return RestoreVFolderPayload(id=vfolder_id)
406406

407407
async def purge(self, vfolder_id: UUID) -> PurgeVFolderPayload:
408-
"""Permanently delete a vfolder."""
409-
me = current_user()
410-
if me is None:
411-
raise UnreachableError("User context is not available")
412-
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
408+
"""Permanently delete a vfolder. RBAC enforced."""
409+
action = PurgeVFolderV2Action(vfolder_id=vfolder_id)
413410
await self._processors.vfolder.purge_v2.wait_for_complete(action)
414411
return PurgeVFolderPayload(id=vfolder_id)
415412

@@ -498,11 +495,8 @@ async def bulk_delete(self, input: BulkDeleteVFoldersInput) -> BulkDeleteVFolder
498495

499496
async def bulk_purge(self, input: BulkPurgeVFoldersInput) -> BulkPurgeVFoldersPayload:
500497
"""Permanently purge multiple vfolders."""
501-
me = current_user()
502-
if me is None:
503-
raise UnreachableError("User context is not available")
504498
for vfolder_id in input.ids:
505-
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
499+
action = PurgeVFolderV2Action(vfolder_id=vfolder_id)
506500
await self._processors.vfolder.purge_v2.wait_for_complete(action)
507501
return BulkPurgeVFoldersPayload(purged_count=len(input.ids))
508502

src/ai/backend/manager/services/vfolder/actions/vfolder_v2.py

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,7 @@
44
from dataclasses import dataclass
55
from typing import override
66

7-
from ai.backend.common.data.permission.types import (
8-
EntityType,
9-
RBACElementType,
10-
ScopeType,
11-
)
12-
from ai.backend.manager.actions.action import BaseActionResult
13-
from ai.backend.manager.actions.action.scope import BaseScopeAction
7+
from ai.backend.common.data.permission.types import RBACElementType
148
from ai.backend.manager.actions.types import ActionOperationType
159
from ai.backend.manager.data.permission.types import RBACElementRef
1610
from ai.backend.manager.services.vfolder.actions.base import (
@@ -60,32 +54,24 @@ def target_entity_id(self) -> str:
6054

6155

6256
@dataclass
63-
class PurgeVFolderV2Action(BaseScopeAction):
64-
user_id: uuid.UUID
57+
class PurgeVFolderV2Action(VFolderSingleEntityAction):
58+
"""Permanently purge a vfolder by ID with RBAC enforcement."""
59+
6560
vfolder_id: uuid.UUID
6661

6762
@override
68-
@classmethod
69-
def entity_type(cls) -> EntityType:
70-
return EntityType.VFOLDER
63+
def entity_id(self) -> str | None:
64+
return str(self.vfolder_id)
7165

7266
@override
7367
@classmethod
7468
def operation_type(cls) -> ActionOperationType:
7569
return ActionOperationType.PURGE
7670

7771
@override
78-
def entity_id(self) -> str | None:
72+
def target_entity_id(self) -> str:
7973
return str(self.vfolder_id)
8074

81-
@override
82-
def scope_type(self) -> ScopeType:
83-
return ScopeType.USER
84-
85-
@override
86-
def scope_id(self) -> str:
87-
return str(self.user_id)
88-
8975
@override
9076
def target_element(self) -> RBACElementRef:
9177
return RBACElementRef(
@@ -95,9 +81,13 @@ def target_element(self) -> RBACElementRef:
9581

9682

9783
@dataclass
98-
class PurgeVFolderV2ActionResult(BaseActionResult):
84+
class PurgeVFolderV2ActionResult(VFolderSingleEntityActionResult):
9985
vfolder_id: uuid.UUID
10086

10187
@override
10288
def entity_id(self) -> str | None:
10389
return str(self.vfolder_id)
90+
91+
@override
92+
def target_entity_id(self) -> str:
93+
return str(self.vfolder_id)

src/ai/backend/manager/services/vfolder/processors/vfolder.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ class VFolderProcessors(AbstractProcessorPackage):
161161
CreateUploadSessionV2Action, CreateUploadSessionV2ActionResult
162162
]
163163
delete_v2: SingleEntityActionProcessor[DeleteVFolderV2Action, DeleteVFolderV2ActionResult]
164-
purge_v2: ActionProcessor[PurgeVFolderV2Action, PurgeVFolderV2ActionResult]
164+
purge_v2: SingleEntityActionProcessor[PurgeVFolderV2Action, PurgeVFolderV2ActionResult]
165165
clone_v2: ActionProcessor[CloneVFolderV2Action, CloneVFolderV2ActionResult]
166166

167167
def __init__(
@@ -257,7 +257,9 @@ def __init__(
257257
self.delete_v2 = SingleEntityActionProcessor(
258258
service.delete_v2, action_monitors, validators=single_entity_rbac_validators
259259
)
260-
self.purge_v2 = ActionProcessor(service.purge_v2, action_monitors)
260+
self.purge_v2 = SingleEntityActionProcessor(
261+
service.purge_v2, action_monitors, validators=single_entity_rbac_validators
262+
)
261263
self.clone_v2 = ActionProcessor(service.clone_v2, action_monitors)
262264

263265
@override

src/ai/backend/manager/services/vfolder/services/vfolder.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,19 +1645,17 @@ async def delete_v2(self, action: DeleteVFolderV2Action) -> DeleteVFolderV2Actio
16451645
return DeleteVFolderV2ActionResult(vfolder_id=action.vfolder_id)
16461646

16471647
async def purge_v2(self, action: PurgeVFolderV2Action) -> PurgeVFolderV2ActionResult:
1648-
"""Purge a vfolder permanently (v2). Resolves policy internally from user_id."""
1649-
user = await self._user_repository.get_user_by_uuid(action.user_id)
1650-
if not user.domain_name:
1651-
raise VFolderInvalidParameter("User has no domain assigned")
1652-
vfolder_data = await self._vfolder_repository.get_by_id_validated(
1653-
action.vfolder_id, user.id, user.domain_name
1654-
)
1648+
"""Permanently purge a vfolder by ID. RBAC enforced at processor level."""
1649+
me = current_user()
1650+
if me is None:
1651+
raise UnreachableError("User context is not available")
1652+
vfolder_data = await self._vfolder_repository.get_by_id(action.vfolder_id)
16551653

1656-
# Host permission check — resolved from user_id
1654+
# Host permission check — resolved from current user context
16571655
await self._vfolder_repository.ensure_host_permission_allowed_by_user(
16581656
vfolder_data.host,
16591657
permission=VFolderHostPermission.DELETE,
1660-
user_uuid=action.user_id,
1658+
user_uuid=me.user_id,
16611659
)
16621660

16631661
await self._vfolder_repository.delete_vfolders_forever([action.vfolder_id])

tests/component/vfolder_v2/test_vfolder_mutation.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
"""Component tests for v2 VFolder RBAC-enforced delete and restore mutations via SDK.
1+
"""Component tests for v2 VFolder RBAC-enforced delete, restore, and purge mutations via SDK.
22
3-
Exercises delete and restore mutations through the real HTTP server +
3+
Exercises delete, restore, and purge mutations through the real HTTP server +
44
V2ClientRegistry SDK.
55
"""
66

@@ -225,3 +225,15 @@ async def test_superadmin_trash_then_restore(
225225
await admin_v2_registry.vfolder.delete(project_vfolder.id)
226226
payload = await admin_v2_registry.vfolder.restore(project_vfolder.id)
227227
assert payload.id == project_vfolder.id
228+
229+
230+
class TestPurgeVFolderRBAC:
231+
"""POST /v2/vfolders/{id}/purge -- SingleEntityActionProcessor RBAC."""
232+
233+
async def test_regular_user_denied(
234+
self,
235+
user_v2_registry: V2ClientRegistry,
236+
project_vfolder: ProjectVFolderFixtureData,
237+
) -> None:
238+
with pytest.raises(PermissionDeniedError):
239+
await user_v2_registry.vfolder.purge(project_vfolder.id)

0 commit comments

Comments
 (0)