Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/11165.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add RBAC-enforced VFolder purge v2 mutation with SingleEntityActionProcessor.
12 changes: 3 additions & 9 deletions src/ai/backend/manager/api/adapters/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,11 +405,8 @@ async def restore(self, vfolder_id: UUID) -> RestoreVFolderPayload:
return RestoreVFolderPayload(id=vfolder_id)

async def purge(self, vfolder_id: UUID) -> PurgeVFolderPayload:
"""Permanently delete a vfolder."""
me = current_user()
if me is None:
raise UnreachableError("User context is not available")
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
Comment thread
fregataa marked this conversation as resolved.
"""Permanently delete a vfolder. RBAC enforced."""
action = PurgeVFolderV2Action(vfolder_id=vfolder_id)
await self._processors.vfolder.purge_v2.wait_for_complete(action)
return PurgeVFolderPayload(id=vfolder_id)

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

async def bulk_purge(self, input: BulkPurgeVFoldersInput) -> BulkPurgeVFoldersPayload:
"""Permanently purge multiple vfolders."""
me = current_user()
if me is None:
raise UnreachableError("User context is not available")
for vfolder_id in input.ids:
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
action = PurgeVFolderV2Action(vfolder_id=vfolder_id)
await self._processors.vfolder.purge_v2.wait_for_complete(action)
return BulkPurgeVFoldersPayload(purged_count=len(input.ids))

Expand Down
34 changes: 12 additions & 22 deletions src/ai/backend/manager/services/vfolder/actions/vfolder_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@
from dataclasses import dataclass
from typing import override

from ai.backend.common.data.permission.types import (
EntityType,
RBACElementType,
ScopeType,
)
from ai.backend.manager.actions.action import BaseActionResult
from ai.backend.manager.actions.action.scope import BaseScopeAction
from ai.backend.common.data.permission.types import RBACElementType
from ai.backend.manager.actions.types import ActionOperationType
from ai.backend.manager.data.permission.types import RBACElementRef
from ai.backend.manager.services.vfolder.actions.base import (
Expand Down Expand Up @@ -60,32 +54,24 @@ def target_entity_id(self) -> str:


@dataclass
class PurgeVFolderV2Action(BaseScopeAction):
user_id: uuid.UUID
class PurgeVFolderV2Action(VFolderSingleEntityAction):
"""Permanently purge a vfolder by ID with RBAC enforcement."""

vfolder_id: uuid.UUID

@override
@classmethod
def entity_type(cls) -> EntityType:
return EntityType.VFOLDER
def entity_id(self) -> str | None:
return str(self.vfolder_id)

@override
@classmethod
def operation_type(cls) -> ActionOperationType:
return ActionOperationType.PURGE

@override
def entity_id(self) -> str | None:
def target_entity_id(self) -> str:
return str(self.vfolder_id)

@override
def scope_type(self) -> ScopeType:
return ScopeType.USER

@override
def scope_id(self) -> str:
return str(self.user_id)

@override
def target_element(self) -> RBACElementRef:
return RBACElementRef(
Expand All @@ -95,9 +81,13 @@ def target_element(self) -> RBACElementRef:


@dataclass
class PurgeVFolderV2ActionResult(BaseActionResult):
class PurgeVFolderV2ActionResult(VFolderSingleEntityActionResult):
vfolder_id: uuid.UUID

@override
def entity_id(self) -> str | None:
return str(self.vfolder_id)

@override
def target_entity_id(self) -> str:
return str(self.vfolder_id)
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ class VFolderProcessors(AbstractProcessorPackage):
CreateUploadSessionV2Action, CreateUploadSessionV2ActionResult
]
delete_v2: SingleEntityActionProcessor[DeleteVFolderV2Action, DeleteVFolderV2ActionResult]
purge_v2: ActionProcessor[PurgeVFolderV2Action, PurgeVFolderV2ActionResult]
purge_v2: SingleEntityActionProcessor[PurgeVFolderV2Action, PurgeVFolderV2ActionResult]
clone_v2: ActionProcessor[CloneVFolderV2Action, CloneVFolderV2ActionResult]

def __init__(
Expand Down Expand Up @@ -257,7 +257,9 @@ def __init__(
self.delete_v2 = SingleEntityActionProcessor(
service.delete_v2, action_monitors, validators=single_entity_rbac_validators
)
self.purge_v2 = ActionProcessor(service.purge_v2, action_monitors)
self.purge_v2 = SingleEntityActionProcessor(
service.purge_v2, action_monitors, validators=single_entity_rbac_validators
)
self.clone_v2 = ActionProcessor(service.clone_v2, action_monitors)

@override
Expand Down
16 changes: 7 additions & 9 deletions src/ai/backend/manager/services/vfolder/services/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1645,19 +1645,17 @@ async def delete_v2(self, action: DeleteVFolderV2Action) -> DeleteVFolderV2Actio
return DeleteVFolderV2ActionResult(vfolder_id=action.vfolder_id)

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

# Host permission check — resolved from user_id
# Host permission check — resolved from current user context
await self._vfolder_repository.ensure_host_permission_allowed_by_user(
vfolder_data.host,
permission=VFolderHostPermission.DELETE,
user_uuid=action.user_id,
user_uuid=me.user_id,
)

await self._vfolder_repository.delete_vfolders_forever([action.vfolder_id])
Expand Down
16 changes: 14 additions & 2 deletions tests/component/vfolder_v2/test_vfolder_mutation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Component tests for v2 VFolder RBAC-enforced delete and restore mutations via SDK.
"""Component tests for v2 VFolder RBAC-enforced delete, restore, and purge mutations via SDK.

Exercises delete and restore mutations through the real HTTP server +
Exercises delete, restore, and purge mutations through the real HTTP server +
V2ClientRegistry SDK.
"""

Expand Down Expand Up @@ -225,3 +225,15 @@ async def test_superadmin_trash_then_restore(
await admin_v2_registry.vfolder.delete(project_vfolder.id)
payload = await admin_v2_registry.vfolder.restore(project_vfolder.id)
assert payload.id == project_vfolder.id


class TestPurgeVFolderRBAC:
"""POST /v2/vfolders/{id}/purge -- SingleEntityActionProcessor RBAC."""

async def test_regular_user_denied(
self,
user_v2_registry: V2ClientRegistry,
project_vfolder: ProjectVFolderFixtureData,
) -> None:
with pytest.raises(PermissionDeniedError):
await user_v2_registry.vfolder.purge(project_vfolder.id)
Comment thread
fregataa marked this conversation as resolved.
Loading