Skip to content

Commit 2f57cbd

Browse files
fregataaclaude
andcommitted
refactor(BA-5737): replace legacy delete/purge with RBAC, add update v2
- Replace DeleteVFolderV2Action / PurgeVFolderV2Action (BaseScopeAction, user_id, no RBAC) with VFolderSingleEntityAction variants (vfolder_id only, SingleEntityActionProcessor + single_entity_rbac_validators). RBAC scope-chain resolves project membership automatically. - Existing adapter.delete / adapter.purge / bulk_delete / bulk_purge no longer require user_id — all callers updated. - delete_v2 service uses Updater + VFolderTrashUpdaterSpec + execute_updater pattern; purge_v2 uses get_by_id + delete_vfolders_forever + storage removal. - Remove bulk action (BulkDeleteVFoldersInProject*) and project_id from delete/purge across all layers. - Add update_vfolder_v2 mutation (GQL + REST PATCH + SDK + CLI) backed by existing UpdateVFolderAttributeAction with RBAC. - Remove unused ProjectVFolderIdPathParam. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9d50124 commit 2f57cbd

19 files changed

Lines changed: 376 additions & 507 deletions

File tree

changes/11139.feature.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Add project-scoped `createProjectVfolderV2`, `deleteProjectVfolderV2`, and `bulkDeleteProjectVfoldersV2` GraphQL mutations with RBAC enforcement.
1+
Add project-scoped `createProjectVfolderV2` GraphQL mutation and enforce RBAC on `deleteVfolderV2`, `purgeVfolderV2`, and new `updateVfolderV2` across GraphQL, REST v2, SDK, and CLI.

src/ai/backend/client/cli/v2/vfolder/commands.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,72 @@ async def _run() -> None:
163163
asyncio.run(_run())
164164

165165

166+
@vfolder.command(name="project-create")
167+
@click.argument("project_id", type=click.UUID)
168+
@click.option("--name", required=True, help="VFolder name.")
169+
@click.option(
170+
"--usage-mode",
171+
default="general",
172+
type=click.Choice(["general", "model", "data"], case_sensitive=False),
173+
help="Usage mode of the vfolder.",
174+
)
175+
@click.option("--host", default=None, type=str, help="Storage host.")
176+
@click.option("--cloneable", is_flag=True, default=False, help="Allow cloning.")
177+
def project_create(
178+
project_id: UUID,
179+
name: str,
180+
usage_mode: str,
181+
host: str | None,
182+
cloneable: bool,
183+
) -> None:
184+
"""Create a vfolder owned by a project."""
185+
186+
from ai.backend.common.dto.manager.v2.vfolder.request import CreateVFolderInput
187+
from ai.backend.common.dto.manager.v2.vfolder.types import VFolderUsageMode
188+
189+
input_dto = CreateVFolderInput(
190+
name=name,
191+
usage_mode=VFolderUsageMode(usage_mode),
192+
host=host,
193+
cloneable=cloneable,
194+
)
195+
196+
async def _run() -> None:
197+
registry = await create_v2_registry(load_v2_config())
198+
try:
199+
result = await registry.vfolder.create_in_project(project_id, input_dto)
200+
print_result(result)
201+
finally:
202+
await registry.close()
203+
204+
asyncio.run(_run())
205+
206+
207+
@vfolder.command()
208+
@click.argument("vfolder_id", type=click.UUID)
209+
@click.option("--name", default=None, type=str, help="New vfolder name.")
210+
@click.option("--cloneable", default=None, type=bool, help="Cloneable setting.")
211+
@click.option("--permission", default=None, type=str, help="Permission (ro, rw, wd).")
212+
def update(
213+
vfolder_id: UUID, name: str | None, cloneable: bool | None, permission: str | None
214+
) -> None:
215+
"""Update vfolder attributes."""
216+
217+
from ai.backend.common.dto.manager.v2.vfolder.request import UpdateVFolderInput
218+
219+
body = UpdateVFolderInput(name=name, cloneable=cloneable, permission=permission)
220+
221+
async def _run() -> None:
222+
registry = await create_v2_registry(load_v2_config())
223+
try:
224+
result = await registry.vfolder.update(vfolder_id, body)
225+
print_result(result)
226+
finally:
227+
await registry.close()
228+
229+
asyncio.run(_run())
230+
231+
166232
@vfolder.command()
167233
@click.argument("vfolder_id", type=click.UUID)
168234
def delete(vfolder_id: UUID) -> None:

src/ai/backend/client/v2/domains_v2/vfolder.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
MkdirInput,
1919
MoveFileInput,
2020
SearchVFoldersInput,
21+
UpdateVFolderInput,
2122
)
2223
from ai.backend.common.dto.manager.v2.vfolder.response import (
2324
BulkDeleteVFoldersPayload,
@@ -34,6 +35,7 @@
3435
MoveFilePayload,
3536
PurgeVFolderPayload,
3637
SearchVFoldersPayload,
38+
UpdateVFolderPayload,
3739
VFolderNode,
3840
)
3941

@@ -77,6 +79,32 @@ async def create(self, request: CreateVFolderInput) -> CreateVFolderPayload:
7779
response_model=CreateVFolderPayload,
7880
)
7981

82+
async def create_in_project(
83+
self,
84+
project_id: UUID,
85+
request: CreateVFolderInput,
86+
) -> CreateVFolderPayload:
87+
"""Create a vfolder owned by a project."""
88+
return await self._client.typed_request(
89+
"POST",
90+
f"{_PATH}/projects/{project_id}",
91+
request=request,
92+
response_model=CreateVFolderPayload,
93+
)
94+
95+
async def update(
96+
self,
97+
vfolder_id: UUID,
98+
request: UpdateVFolderInput,
99+
) -> UpdateVFolderPayload:
100+
"""Update vfolder attributes."""
101+
return await self._client.typed_request(
102+
"PATCH",
103+
f"{_PATH}/{vfolder_id}",
104+
request=request,
105+
response_model=UpdateVFolderPayload,
106+
)
107+
80108
async def create_upload_session(
81109
self,
82110
vfolder_id: UUID,

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

Lines changed: 36 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
MkdirInput,
2323
MoveFileInput,
2424
SearchVFoldersInput,
25+
UpdateVFolderInput,
2526
VFolderFilter,
2627
VFolderOrder,
2728
)
@@ -41,6 +42,7 @@
4142
MoveFilePayload,
4243
PurgeVFolderPayload,
4344
SearchVFoldersPayload,
45+
UpdateVFolderPayload,
4446
VFolderNode,
4547
)
4648
from ai.backend.common.dto.manager.v2.vfolder.types import (
@@ -95,14 +97,17 @@
9597
combine_conditions_or,
9698
negate_conditions,
9799
)
100+
from ai.backend.manager.repositories.base.updater import Updater
98101
from ai.backend.manager.repositories.vfolder.types import (
99102
ProjectVFolderSearchScope,
100103
UserVFolderSearchScope,
101104
)
105+
from ai.backend.manager.repositories.vfolder.updaters import VFolderAttributeUpdaterSpec
102106
from ai.backend.manager.services.deployment.actions.create_deployment import CreateDeploymentAction
103107
from ai.backend.manager.services.vfolder.actions.admin_search_vfolders import (
104108
AdminSearchVFoldersAction,
105109
)
110+
from ai.backend.manager.services.vfolder.actions.base import UpdateVFolderAttributeAction
106111
from ai.backend.manager.services.vfolder.actions.batch_load_by_ids import (
107112
BatchLoadVFoldersByIdsAction,
108113
)
@@ -126,14 +131,13 @@
126131
CreateUploadSessionV2Action,
127132
)
128133
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
129-
BulkDeleteVFoldersInProjectAction,
130134
CreateVFolderInProjectAction,
131-
DeleteVFolderInProjectAction,
132135
)
133136
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
134137
DeleteVFolderV2Action,
135138
PurgeVFolderV2Action,
136139
)
140+
from ai.backend.manager.types import OptionalState
137141

138142
from .base import BaseAdapter
139143

@@ -392,38 +396,6 @@ async def create_in_project(
392396
result = await self._processors.vfolder.create_vfolder_in_project.wait_for_complete(action)
393397
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))
394398

395-
async def delete_in_project(
396-
self,
397-
project_id: UUID,
398-
vfolder_id: UUID,
399-
) -> DeleteVFolderPayload:
400-
"""Soft-delete a vfolder belonging to ``project_id``.
401-
402-
Uses ``DeleteVFolderInProjectAction`` which is PROJECT-scoped so the
403-
caller must hold DELETE permission on the project.
404-
"""
405-
action = DeleteVFolderInProjectAction(
406-
project_id=project_id,
407-
vfolder_id=vfolder_id,
408-
)
409-
await self._processors.vfolder.delete_vfolder_in_project.wait_for_complete(action)
410-
return DeleteVFolderPayload(id=vfolder_id)
411-
412-
async def bulk_delete_in_project(
413-
self,
414-
project_id: UUID,
415-
input: BulkDeleteVFoldersInput,
416-
) -> BulkDeleteVFoldersPayload:
417-
"""Soft-delete multiple vfolders that all belong to ``project_id``."""
418-
action = BulkDeleteVFoldersInProjectAction(
419-
project_id=project_id,
420-
vfolder_ids=list(input.ids),
421-
)
422-
result = await self._processors.vfolder.bulk_delete_vfolders_in_project.wait_for_complete(
423-
action
424-
)
425-
return BulkDeleteVFoldersPayload(deleted_count=len(result.deleted_vfolder_ids))
426-
427399
async def create_upload_session(
428400
self, vfolder_id: UUID, input: CreateUploadSessionInput
429401
) -> CreateUploadSessionPayload:
@@ -447,21 +419,39 @@ async def get(self, vfolder_id: UUID) -> VFolderNode:
447419
)
448420
return self._vfolder_data_to_node(result.vfolder)
449421

450-
async def delete(self, vfolder_id: UUID) -> DeleteVFolderPayload:
451-
"""Soft-delete a vfolder (move to trash)."""
422+
async def update(self, vfolder_id: UUID, input: UpdateVFolderInput) -> UpdateVFolderPayload:
423+
"""Update vfolder attributes. RBAC enforced."""
452424
me = current_user()
453425
if me is None:
454426
raise UnreachableError("User context is not available")
455-
action = DeleteVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
427+
spec = VFolderAttributeUpdaterSpec()
428+
if isinstance(input.name, str):
429+
spec.name = OptionalState.update(input.name)
430+
if input.cloneable is not None:
431+
spec.cloneable = OptionalState.update(input.cloneable)
432+
if input.permission is not None:
433+
spec.mount_permission = OptionalState.update(VFolderPermission(input.permission.value))
434+
updater = Updater(spec=spec, pk_value=vfolder_id)
435+
action = UpdateVFolderAttributeAction(
436+
user_uuid=me.user_id,
437+
vfolder_uuid=vfolder_id,
438+
updater=updater,
439+
)
440+
await self._processors.vfolder.update_vfolder_attribute.wait_for_complete(action)
441+
vfolder_data = await self._processors.vfolder.get_v2.wait_for_complete(
442+
GetVFolderV2Action(vfolder_uuid=vfolder_id)
443+
)
444+
return UpdateVFolderPayload(vfolder=self._vfolder_data_to_node(vfolder_data.vfolder))
445+
446+
async def delete(self, vfolder_id: UUID) -> DeleteVFolderPayload:
447+
"""Soft-delete a vfolder (move to trash). RBAC enforced."""
448+
action = DeleteVFolderV2Action(vfolder_id=vfolder_id)
456449
await self._processors.vfolder.delete_v2.wait_for_complete(action)
457450
return DeleteVFolderPayload(id=vfolder_id)
458451

459452
async def purge(self, vfolder_id: UUID) -> PurgeVFolderPayload:
460-
"""Permanently delete a vfolder."""
461-
me = current_user()
462-
if me is None:
463-
raise UnreachableError("User context is not available")
464-
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
453+
"""Permanently delete a vfolder. RBAC enforced."""
454+
action = PurgeVFolderV2Action(vfolder_id=vfolder_id)
465455
await self._processors.vfolder.purge_v2.wait_for_complete(action)
466456
return PurgeVFolderPayload(id=vfolder_id)
467457

@@ -545,22 +535,16 @@ async def deploy(
545535
)
546536

547537
async def bulk_delete(self, input: BulkDeleteVFoldersInput) -> BulkDeleteVFoldersPayload:
548-
"""Soft-delete multiple vfolders."""
549-
me = current_user()
550-
if me is None:
551-
raise UnreachableError("User context is not available")
538+
"""Soft-delete multiple vfolders. RBAC enforced per item."""
552539
for vfolder_id in input.ids:
553-
action = DeleteVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
540+
action = DeleteVFolderV2Action(vfolder_id=vfolder_id)
554541
await self._processors.vfolder.delete_v2.wait_for_complete(action)
555542
return BulkDeleteVFoldersPayload(deleted_count=len(input.ids))
556543

557544
async def bulk_purge(self, input: BulkPurgeVFoldersInput) -> BulkPurgeVFoldersPayload:
558-
"""Permanently purge multiple vfolders."""
559-
me = current_user()
560-
if me is None:
561-
raise UnreachableError("User context is not available")
545+
"""Permanently purge multiple vfolders. RBAC enforced per item."""
562546
for vfolder_id in input.ids:
563-
action = PurgeVFolderV2Action(user_id=me.user_id, vfolder_id=vfolder_id)
547+
action = PurgeVFolderV2Action(vfolder_id=vfolder_id)
564548
await self._processors.vfolder.purge_v2.wait_for_complete(action)
565549
return BulkPurgeVFoldersPayload(purged_count=len(input.ids))
566550

src/ai/backend/manager/api/gql/schema.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -412,18 +412,17 @@
412412
)
413413
from .vfolder_v2 import (
414414
admin_vfolders_v2,
415-
bulk_delete_project_vfolders_v2,
416415
bulk_delete_vfolders_v2,
417416
bulk_purge_vfolders_v2,
418417
clone_vfolder_v2,
419418
create_project_vfolder_v2,
420419
create_vfolder_v2,
421-
delete_project_vfolder_v2,
422420
delete_vfolder_v2,
423421
deploy_vfolder_v2,
424422
my_vfolders,
425423
project_vfolders,
426424
purge_vfolder_v2,
425+
update_vfolder_v2,
427426
vfolder_create_download_session_v2,
428427
vfolder_create_upload_session_v2,
429428
vfolder_delete_files_v2,
@@ -845,12 +844,11 @@ class Mutation:
845844
create_vfolder_v2 = create_vfolder_v2
846845
delete_vfolder_v2 = delete_vfolder_v2
847846
purge_vfolder_v2 = purge_vfolder_v2
847+
update_vfolder_v2 = update_vfolder_v2
848848
deploy_vfolder_v2 = deploy_vfolder_v2
849849
bulk_delete_vfolders_v2 = bulk_delete_vfolders_v2
850850
bulk_purge_vfolders_v2 = bulk_purge_vfolders_v2
851851
create_project_vfolder_v2 = create_project_vfolder_v2
852-
delete_project_vfolder_v2 = delete_project_vfolder_v2
853-
bulk_delete_project_vfolders_v2 = bulk_delete_project_vfolders_v2
854852
clone_vfolder_v2 = clone_vfolder_v2
855853
vfolder_list_files_v2 = vfolder_list_files_v2
856854
vfolder_mkdir_v2 = vfolder_mkdir_v2

src/ai/backend/manager/api/gql/vfolder_v2/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@
66

77
from .resolver import (
88
admin_vfolders_v2,
9-
bulk_delete_project_vfolders_v2,
109
bulk_delete_vfolders_v2,
1110
bulk_purge_vfolders_v2,
1211
clone_vfolder_v2,
1312
create_project_vfolder_v2,
1413
create_vfolder_v2,
15-
delete_project_vfolder_v2,
1614
delete_vfolder_v2,
1715
deploy_vfolder_v2,
1816
my_vfolders,
1917
project_vfolders,
2018
purge_vfolder_v2,
19+
update_vfolder_v2,
2120
vfolder_create_download_session_v2,
2221
vfolder_create_upload_session_v2,
2322
vfolder_delete_files_v2,
@@ -48,15 +47,14 @@
4847
"project_vfolders",
4948
"vfolder_v2",
5049
# Mutations
51-
"bulk_delete_project_vfolders_v2",
5250
"bulk_delete_vfolders_v2",
5351
"bulk_purge_vfolders_v2",
5452
"create_project_vfolder_v2",
5553
"create_vfolder_v2",
56-
"delete_project_vfolder_v2",
5754
"delete_vfolder_v2",
5855
"deploy_vfolder_v2",
5956
"purge_vfolder_v2",
57+
"update_vfolder_v2",
6058
"clone_vfolder_v2",
6159
"vfolder_list_files_v2",
6260
"vfolder_mkdir_v2",

src/ai/backend/manager/api/gql/vfolder_v2/resolver/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
"""VFolder GraphQL resolver package."""
22

33
from .mutation import (
4-
bulk_delete_project_vfolders_v2,
54
bulk_delete_vfolders_v2,
65
bulk_purge_vfolders_v2,
76
clone_vfolder_v2,
87
create_project_vfolder_v2,
98
create_vfolder_v2,
10-
delete_project_vfolder_v2,
119
delete_vfolder_v2,
1210
deploy_vfolder_v2,
1311
purge_vfolder_v2,
12+
update_vfolder_v2,
1413
vfolder_create_download_session_v2,
1514
vfolder_create_upload_session_v2,
1615
vfolder_delete_files_v2,
@@ -27,15 +26,14 @@
2726
"project_vfolders",
2827
"vfolder_v2",
2928
# Mutations
30-
"bulk_delete_project_vfolders_v2",
3129
"bulk_delete_vfolders_v2",
3230
"bulk_purge_vfolders_v2",
3331
"create_project_vfolder_v2",
3432
"create_vfolder_v2",
35-
"delete_project_vfolder_v2",
3633
"delete_vfolder_v2",
3734
"deploy_vfolder_v2",
3835
"purge_vfolder_v2",
36+
"update_vfolder_v2",
3937
"clone_vfolder_v2",
4038
"vfolder_list_files_v2",
4139
"vfolder_mkdir_v2",

0 commit comments

Comments
 (0)