Skip to content

Commit 5812862

Browse files
fregataaclaude
andcommitted
feat(BA-5765): add RBAC-enforced VFolder purge mutation
Add PurgeVFolderV2RBACAction (SingleEntityActionProcessor + RBAC). Adapter purge() routes to RBAC path; bulk_purge() stays on legacy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 78d6b29 commit 5812862

6 files changed

Lines changed: 291 additions & 6 deletions

File tree

changes/BA-5765.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: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@
125125
from ai.backend.manager.services.vfolder.actions.upload_session_v2 import (
126126
CreateUploadSessionV2Action,
127127
)
128+
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
129+
PurgeVFolderV2RBACAction,
130+
)
128131
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
129132
DeleteVFolderV2Action,
130133
PurgeVFolderV2Action,
@@ -394,12 +397,9 @@ async def delete(self, vfolder_id: UUID) -> DeleteVFolderPayload:
394397
return DeleteVFolderPayload(id=vfolder_id)
395398

396399
async def purge(self, vfolder_id: UUID) -> PurgeVFolderPayload:
397-
"""Permanently delete a vfolder."""
398-
me = current_user()
399-
if me is None:
400-
raise UnreachableError("User context is not available")
401-
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
402-
await self._processors.vfolder.purge_v2.wait_for_complete(action)
400+
"""Permanently delete a vfolder. RBAC enforced."""
401+
action = PurgeVFolderV2RBACAction(vfolder_id=vfolder_id)
402+
await self._processors.vfolder.purge_v2_rbac.wait_for_complete(action)
403403
return PurgeVFolderPayload(id=vfolder_id)
404404

405405
async def deploy(
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""RBAC-enforced v2 VFolder actions (purge).
2+
3+
Purge targets an existing vfolder by ID and uses
4+
``SingleEntityActionProcessor`` with ``single_entity_rbac_validators``.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import uuid
10+
from dataclasses import dataclass
11+
from typing import override
12+
13+
from ai.backend.common.data.permission.types import RBACElementType
14+
from ai.backend.manager.actions.types import ActionOperationType
15+
from ai.backend.manager.data.permission.types import RBACElementRef
16+
from ai.backend.manager.services.vfolder.actions.base import (
17+
VFolderSingleEntityAction,
18+
VFolderSingleEntityActionResult,
19+
)
20+
21+
# ---------------------------------------------------------------------------
22+
# Purge v2 RBAC (single-entity -- scope chain resolves project)
23+
# ---------------------------------------------------------------------------
24+
25+
26+
@dataclass
27+
class PurgeVFolderV2RBACAction(VFolderSingleEntityAction):
28+
"""Permanently purge a vfolder by ID with RBAC enforcement."""
29+
30+
vfolder_id: uuid.UUID
31+
32+
@override
33+
def entity_id(self) -> str | None:
34+
return str(self.vfolder_id)
35+
36+
@override
37+
@classmethod
38+
def operation_type(cls) -> ActionOperationType:
39+
return ActionOperationType.PURGE
40+
41+
@override
42+
def target_entity_id(self) -> str:
43+
return str(self.vfolder_id)
44+
45+
@override
46+
def target_element(self) -> RBACElementRef:
47+
return RBACElementRef(
48+
element_type=RBACElementType.VFOLDER,
49+
element_id=str(self.vfolder_id),
50+
)
51+
52+
53+
@dataclass
54+
class PurgeVFolderV2RBACActionResult(VFolderSingleEntityActionResult):
55+
vfolder_id: uuid.UUID
56+
57+
@override
58+
def entity_id(self) -> str | None:
59+
return str(self.vfolder_id)
60+
61+
@override
62+
def target_entity_id(self) -> str:
63+
return str(self.vfolder_id)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@
9292
CreateUploadSessionV2Action,
9393
CreateUploadSessionV2ActionResult,
9494
)
95+
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
96+
PurgeVFolderV2RBACAction,
97+
PurgeVFolderV2RBACActionResult,
98+
)
9599
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
96100
DeleteVFolderV2Action,
97101
DeleteVFolderV2ActionResult,
@@ -163,6 +167,9 @@ class VFolderProcessors(AbstractProcessorPackage):
163167
delete_v2: ActionProcessor[DeleteVFolderV2Action, DeleteVFolderV2ActionResult]
164168
purge_v2: ActionProcessor[PurgeVFolderV2Action, PurgeVFolderV2ActionResult]
165169
clone_v2: ActionProcessor[CloneVFolderV2Action, CloneVFolderV2ActionResult]
170+
purge_v2_rbac: SingleEntityActionProcessor[
171+
PurgeVFolderV2RBACAction, PurgeVFolderV2RBACActionResult
172+
]
166173

167174
def __init__(
168175
self,
@@ -257,6 +264,9 @@ def __init__(
257264
self.delete_v2 = ActionProcessor(service.delete_v2, action_monitors)
258265
self.purge_v2 = ActionProcessor(service.purge_v2, action_monitors)
259266
self.clone_v2 = ActionProcessor(service.clone_v2, action_monitors)
267+
self.purge_v2_rbac = SingleEntityActionProcessor(
268+
service.purge_v2_rbac, action_monitors, validators=single_entity_rbac_validators
269+
)
260270

261271
@override
262272
def supported_actions(self) -> list[ActionSpec]:
@@ -296,4 +306,5 @@ def supported_actions(self) -> list[ActionSpec]:
296306
DeleteVFolderV2Action.spec(),
297307
PurgeVFolderV2Action.spec(),
298308
CloneVFolderV2Action.spec(),
309+
PurgeVFolderV2RBACAction.spec(),
299310
]

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@
156156
CreateUploadSessionV2Action,
157157
CreateUploadSessionV2ActionResult,
158158
)
159+
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
160+
PurgeVFolderV2RBACAction,
161+
PurgeVFolderV2RBACActionResult,
162+
)
159163
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
160164
DeleteVFolderV2Action,
161165
DeleteVFolderV2ActionResult,
@@ -1664,6 +1668,15 @@ async def purge_v2(self, action: PurgeVFolderV2Action) -> PurgeVFolderV2ActionRe
16641668
await self._remove_vfolder_from_storage(vfolder_data)
16651669
return PurgeVFolderV2ActionResult(vfolder_id=action.vfolder_id)
16661670

1671+
async def purge_v2_rbac(
1672+
self, action: PurgeVFolderV2RBACAction
1673+
) -> PurgeVFolderV2RBACActionResult:
1674+
"""Permanently purge a vfolder by ID. RBAC enforced at processor level."""
1675+
vfolder_data = await self._vfolder_repository.get_by_id(action.vfolder_id)
1676+
await self._vfolder_repository.delete_vfolders_forever([action.vfolder_id])
1677+
await self._remove_vfolder_from_storage(vfolder_data)
1678+
return PurgeVFolderV2RBACActionResult(vfolder_id=action.vfolder_id)
1679+
16671680
async def clone_v2(self, action: CloneVFolderV2Action) -> CloneVFolderV2ActionResult:
16681681
"""Clone a vfolder (v2). Resolves policy internally from user_id."""
16691682
allowed_vfolder_types = (
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Component tests for v2 VFolder RBAC-enforced purge mutation via SDK.
2+
3+
Exercises purge mutation through the real HTTP server + V2ClientRegistry SDK.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import secrets
9+
import uuid
10+
from collections.abc import AsyncIterator
11+
from dataclasses import dataclass
12+
from typing import TYPE_CHECKING
13+
from unittest.mock import MagicMock
14+
15+
import pytest
16+
import yarl
17+
18+
from ai.backend.client.v2.auth import HMACAuth
19+
from ai.backend.client.v2.config import ClientConfig
20+
from ai.backend.client.v2.exceptions import PermissionDeniedError
21+
from ai.backend.client.v2.v2_registry import V2ClientRegistry
22+
from ai.backend.common.types import QuotaScopeID, QuotaScopeType, VFolderUsageMode
23+
from ai.backend.manager.actions.validators import ActionValidators
24+
from ai.backend.manager.actions.validators.rbac import RBACValidators
25+
from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator
26+
from ai.backend.manager.actions.validators.rbac.single_entity import (
27+
SingleEntityActionRBACValidator,
28+
)
29+
from ai.backend.manager.api.adapters.vfolder import VFolderAdapter
30+
from ai.backend.manager.api.rest.routing import RouteRegistry
31+
from ai.backend.manager.api.rest.types import RouteDeps
32+
from ai.backend.manager.api.rest.v2.vfolder.handler import V2VFolderHandler
33+
from ai.backend.manager.api.rest.v2.vfolder.registry import register_v2_vfolder_routes
34+
from ai.backend.manager.data.vfolder.types import (
35+
VFolderMountPermission,
36+
VFolderOperationStatus,
37+
VFolderOwnershipType,
38+
)
39+
from ai.backend.manager.models.utils import ExtendedAsyncSAEngine
40+
from ai.backend.manager.models.vfolder import vfolders
41+
from ai.backend.manager.repositories.permission_controller.repository import (
42+
PermissionControllerRepository,
43+
)
44+
from ai.backend.manager.repositories.user.repository import UserRepository
45+
from ai.backend.manager.repositories.vfolder.repository import VfolderRepository
46+
from ai.backend.manager.services.processors import Processors
47+
from ai.backend.manager.services.vfolder.processors.vfolder import VFolderProcessors
48+
from ai.backend.manager.services.vfolder.services.vfolder import VFolderService
49+
50+
if TYPE_CHECKING:
51+
from sqlalchemy.ext.asyncio.engine import AsyncEngine as SAEngine
52+
from tests.component.conftest import ServerInfo, UserFixtureData
53+
54+
55+
@dataclass(frozen=True)
56+
class ProjectVFolderFixtureData:
57+
id: uuid.UUID
58+
project_id: uuid.UUID
59+
60+
61+
# -- Fixtures ------------------------------------------------------------------
62+
63+
64+
@pytest.fixture()
65+
def rbac_permission_repo(
66+
database_engine: ExtendedAsyncSAEngine,
67+
) -> PermissionControllerRepository:
68+
return PermissionControllerRepository(database_engine)
69+
70+
71+
@pytest.fixture()
72+
def vfolder_processors(
73+
database_engine: ExtendedAsyncSAEngine,
74+
rbac_permission_repo: PermissionControllerRepository,
75+
) -> VFolderProcessors:
76+
"""Override: real scope + single-entity RBAC validators."""
77+
vfolder_repository = VfolderRepository(database_engine)
78+
user_repository = UserRepository(database_engine)
79+
service = VFolderService(
80+
config_provider=MagicMock(),
81+
etcd=MagicMock(),
82+
storage_manager=MagicMock(),
83+
background_task_manager=MagicMock(),
84+
vfolder_repository=vfolder_repository,
85+
user_repository=user_repository,
86+
valkey_stat_client=MagicMock(),
87+
)
88+
return VFolderProcessors(
89+
service=service,
90+
action_monitors=[],
91+
validators=ActionValidators(
92+
rbac=RBACValidators(
93+
scope=ScopeActionRBACValidator(rbac_permission_repo),
94+
single_entity=SingleEntityActionRBACValidator(rbac_permission_repo),
95+
)
96+
),
97+
)
98+
99+
100+
@pytest.fixture()
101+
def server_module_registries(
102+
route_deps: RouteDeps,
103+
vfolder_processors: VFolderProcessors,
104+
) -> list[RouteRegistry]:
105+
"""Register v2 vfolder REST routes with real RBAC."""
106+
processors = MagicMock(spec=Processors)
107+
processors.vfolder = vfolder_processors
108+
adapter = VFolderAdapter(processors)
109+
handler = V2VFolderHandler(adapter=adapter)
110+
v2_reg = RouteRegistry.create("v2", route_deps.cors_options)
111+
v2_reg.add_subregistry(register_v2_vfolder_routes(handler, route_deps))
112+
return [v2_reg]
113+
114+
115+
@pytest.fixture()
116+
async def admin_v2_registry(
117+
server: ServerInfo,
118+
admin_user_fixture: UserFixtureData,
119+
) -> AsyncIterator[V2ClientRegistry]:
120+
registry = await V2ClientRegistry.create(
121+
ClientConfig(endpoint=yarl.URL(server.url)),
122+
HMACAuth(
123+
access_key=admin_user_fixture.keypair.access_key,
124+
secret_key=admin_user_fixture.keypair.secret_key,
125+
),
126+
)
127+
try:
128+
yield registry
129+
finally:
130+
await registry.close()
131+
132+
133+
@pytest.fixture()
134+
async def user_v2_registry(
135+
server: ServerInfo,
136+
regular_user_fixture: UserFixtureData,
137+
) -> AsyncIterator[V2ClientRegistry]:
138+
registry = await V2ClientRegistry.create(
139+
ClientConfig(endpoint=yarl.URL(server.url)),
140+
HMACAuth(
141+
access_key=regular_user_fixture.keypair.access_key,
142+
secret_key=regular_user_fixture.keypair.secret_key,
143+
),
144+
)
145+
try:
146+
yield registry
147+
finally:
148+
await registry.close()
149+
150+
151+
@pytest.fixture()
152+
async def project_vfolder(
153+
db_engine: SAEngine,
154+
domain_fixture: str,
155+
group_fixture: uuid.UUID,
156+
vfolder_host_permission_fixture: None,
157+
) -> AsyncIterator[ProjectVFolderFixtureData]:
158+
"""Seed a project-owned vfolder row directly in the DB."""
159+
unique = secrets.token_hex(4)
160+
vfolder_id = uuid.uuid4()
161+
quota_scope_id = QuotaScopeID(scope_type=QuotaScopeType.PROJECT, scope_id=group_fixture)
162+
async with db_engine.begin() as conn:
163+
await conn.execute(
164+
vfolders.insert().values(
165+
id=vfolder_id,
166+
name=f"test-proj-vfolder-{unique}",
167+
host="local",
168+
domain_name=domain_fixture,
169+
quota_scope_id=str(quota_scope_id),
170+
usage_mode=VFolderUsageMode.GENERAL,
171+
permission=VFolderMountPermission.READ_WRITE,
172+
ownership_type=VFolderOwnershipType.GROUP,
173+
user=None,
174+
group=group_fixture,
175+
creator="admin-test@test.local",
176+
status=VFolderOperationStatus.READY,
177+
cloneable=False,
178+
)
179+
)
180+
yield ProjectVFolderFixtureData(id=vfolder_id, project_id=group_fixture)
181+
async with db_engine.begin() as conn:
182+
await conn.execute(vfolders.delete().where(vfolders.c.id == vfolder_id))
183+
184+
185+
# -- Tests ---------------------------------------------------------------------
186+
187+
188+
class TestPurgeVFolderRBAC:
189+
"""POST /v2/vfolders/{id}/purge -- SingleEntityActionProcessor RBAC."""
190+
191+
async def test_regular_user_denied(
192+
self,
193+
user_v2_registry: V2ClientRegistry,
194+
project_vfolder: ProjectVFolderFixtureData,
195+
) -> None:
196+
with pytest.raises(PermissionDeniedError):
197+
await user_v2_registry.vfolder.purge(project_vfolder.id)

0 commit comments

Comments
 (0)