Skip to content

Commit 3b53e87

Browse files
fregataaclaudelablup-octodog
authored andcommitted
feat(BA-5737): add RBAC-enforced VFolder create mutations (#11139)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: octodog <mu001@lablup.com>
1 parent 715810a commit 3b53e87

21 files changed

Lines changed: 770 additions & 109 deletions

File tree

changes/11139.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add project-scoped `createVFolderInProject` GraphQL mutation with RBAC enforcement via ScopeActionProcessor.

docs/manager/graphql-reference/supergraph.graphql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3948,6 +3948,28 @@ type CreateUserV2Payload
39483948
user: UserV2!
39493949
}
39503950

3951+
"""
3952+
Added in UNRELEASED. Scope-agnostic body for vfolder creation. The owning scope is supplied as a separate mutation argument.
3953+
"""
3954+
input CreateVFolderInScopeInput
3955+
@join__type(graph: STRAWBERRY)
3956+
{
3957+
"""VFolder name."""
3958+
name: String!
3959+
3960+
"""Storage host for the vfolder."""
3961+
host: String = null
3962+
3963+
"""Usage mode of the vfolder."""
3964+
usageMode: VFolderUsageMode! = GENERAL
3965+
3966+
"""Default mount permission of the vfolder."""
3967+
permission: VFolderMountPermission! = READ_WRITE
3968+
3969+
"""Whether the vfolder is cloneable."""
3970+
cloneable: Boolean! = false
3971+
}
3972+
39513973
"""Added in 26.4.2. Input for creating a new virtual folder."""
39523974
input CreateVFolderV2Input
39533975
@join__type(graph: STRAWBERRY)
@@ -10798,6 +10820,11 @@ type Mutation
1079810820
"""Added in 26.4.2. Create a new virtual folder."""
1079910821
createVfolderV2(input: CreateVFolderV2Input!): CreateVFolderV2Payload! @join__field(graph: STRAWBERRY)
1080010822

10823+
"""
10824+
Added in UNRELEASED. Create a virtual folder owned by the specified project. Requires project-scoped CREATE permission.
10825+
"""
10826+
createVFolderInProject(projectId: UUID!, input: CreateVFolderInScopeInput!): CreateVFolderV2Payload! @join__field(graph: STRAWBERRY)
10827+
1080110828
"""Added in 26.4.2. Soft-delete a virtual folder (move to trash)."""
1080210829
deleteVfolderV2(vfolderId: UUID!): DeleteVFolderV2Payload! @join__field(graph: STRAWBERRY)
1080310830

docs/manager/graphql-reference/v2-schema.graphql

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,6 +2469,26 @@ type CreateVFSStoragePayload {
24692469
vfsStorage: VFSStorage!
24702470
}
24712471

2472+
"""
2473+
Added in UNRELEASED. Scope-agnostic body for vfolder creation. The owning scope is supplied as a separate mutation argument.
2474+
"""
2475+
input CreateVFolderInScopeInput {
2476+
"""VFolder name."""
2477+
name: String!
2478+
2479+
"""Storage host for the vfolder."""
2480+
host: String = null
2481+
2482+
"""Usage mode of the vfolder."""
2483+
usageMode: VFolderUsageMode! = GENERAL
2484+
2485+
"""Default mount permission of the vfolder."""
2486+
permission: VFolderMountPermission! = READ_WRITE
2487+
2488+
"""Whether the vfolder is cloneable."""
2489+
cloneable: Boolean! = false
2490+
}
2491+
24722492
"""Added in 26.4.2. Input for creating a new virtual folder."""
24732493
input CreateVFolderV2Input {
24742494
"""VFolder name."""
@@ -6788,6 +6808,11 @@ type Mutation {
67886808
"""Added in 26.4.2. Create a new virtual folder."""
67896809
createVfolderV2(input: CreateVFolderV2Input!): CreateVFolderV2Payload!
67906810

6811+
"""
6812+
Added in UNRELEASED. Create a virtual folder owned by the specified project. Requires project-scoped CREATE permission.
6813+
"""
6814+
createVFolderInProject(projectId: UUID!, input: CreateVFolderInScopeInput!): CreateVFolderV2Payload!
6815+
67916816
"""Added in 26.4.2. Soft-delete a virtual folder (move to trash)."""
67926817
deleteVfolderV2(vfolderId: UUID!): DeleteVFolderV2Payload!
67936818

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,47 @@ 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 CreateVFolderInScopeInput
187+
from ai.backend.common.dto.manager.v2.vfolder.types import VFolderUsageMode
188+
189+
input_dto = CreateVFolderInScopeInput(
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+
166207
@vfolder.command()
167208
@click.argument("vfolder_id", type=click.UUID)
168209
def delete(vfolder_id: UUID) -> None:

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
CreateDownloadSessionInput,
1313
CreateUploadSessionInput,
1414
CreateVFolderInput,
15+
CreateVFolderInScopeInput,
1516
DeleteFilesInput,
1617
DeployVFolderInput,
1718
ListFilesInput,
@@ -78,6 +79,19 @@ async def create(self, request: CreateVFolderInput) -> CreateVFolderPayload:
7879
response_model=CreateVFolderPayload,
7980
)
8081

82+
async def create_in_project(
83+
self,
84+
project_id: UUID,
85+
request: CreateVFolderInScopeInput,
86+
) -> CreateVFolderPayload:
87+
"""Create a vfolder owned by ``project_id``."""
88+
return await self._client.typed_request(
89+
"POST",
90+
f"{_PATH}/projects/{project_id}/create",
91+
request=request,
92+
response_model=CreateVFolderPayload,
93+
)
94+
8195
async def create_upload_session(
8296
self,
8397
vfolder_id: UUID,

src/ai/backend/common/dto/manager/v2/vfolder/request.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"CloneVFolderInput",
3131
"CreateDownloadSessionInput",
3232
"CreateUploadSessionInput",
33+
"CreateVFolderInScopeInput",
3334
"CreateVFolderInput",
3435
"DeleteFilesInput",
3536
"DeleteInvitationInput",
@@ -84,6 +85,38 @@ def strip_and_validate_name(cls, v: object) -> object:
8485
return v
8586

8687

88+
class CreateVFolderInScopeInput(BaseRequestModel):
89+
"""Scope-agnostic body for vfolder creation under a specific scope.
90+
91+
The owning scope (project, user, domain, …) is supplied externally by
92+
the transport layer (REST path segment, GraphQL mutation argument)
93+
and is NOT part of this body. This keeps the body reusable across
94+
scope-specific endpoints without forcing clients to duplicate the
95+
scope identifier.
96+
"""
97+
98+
name: VFolderName = Field(description="VFolder name")
99+
host: str | None = Field(default=None, description="Storage host for the vfolder")
100+
usage_mode: VFolderUsageMode = Field(
101+
default=VFolderUsageMode.GENERAL, description="Usage mode of the vfolder"
102+
)
103+
permission: VFolderPermissionField = Field(
104+
default=VFolderPermissionField.READ_WRITE,
105+
description="Default permission of the vfolder",
106+
)
107+
cloneable: bool = Field(default=False, description="Whether the vfolder is cloneable")
108+
109+
@field_validator("name", mode="before")
110+
@classmethod
111+
def strip_and_validate_name(cls, v: object) -> object:
112+
if isinstance(v, str):
113+
stripped = v.strip()
114+
if not stripped:
115+
raise ValueError("name must not be blank or whitespace-only")
116+
return stripped
117+
return v
118+
119+
87120
class UpdateVFolderInput(BaseRequestModel):
88121
"""Input for updating a virtual folder."""
89122

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
CreateDownloadSessionInput,
1717
CreateUploadSessionInput,
1818
CreateVFolderInput,
19+
CreateVFolderInScopeInput,
1920
DeleteFilesInput,
2021
DeployVFolderInput,
2122
ListFilesInput,
@@ -127,6 +128,9 @@
127128
from ai.backend.manager.services.vfolder.actions.upload_session_v2 import (
128129
CreateUploadSessionV2Action,
129130
)
131+
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
132+
CreateVFolderInProjectAction,
133+
)
130134
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
131135
DeleteVFolderV2Action,
132136
PurgeVFolderV2Action,
@@ -363,6 +367,32 @@ async def create(self, input: CreateVFolderInput) -> CreateVFolderPayload:
363367
result = await self._processors.vfolder.create_vfolder_v2.wait_for_complete(action)
364368
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))
365369

370+
async def create_in_project(
371+
self,
372+
project_id: UUID,
373+
input: CreateVFolderInScopeInput,
374+
) -> CreateVFolderPayload:
375+
"""Create a vfolder owned by the given project.
376+
377+
Uses ``CreateVFolderInProjectAction`` which is PROJECT-scoped so the
378+
caller must hold CREATE permission on the project.
379+
"""
380+
me = current_user()
381+
if me is None:
382+
raise UnreachableError("User context is not available")
383+
action = CreateVFolderInProjectAction(
384+
project_id=project_id,
385+
user_id=me.user_id,
386+
domain_name=me.domain_name,
387+
name=input.name,
388+
host=input.host,
389+
usage_mode=VFolderUsageMode(input.usage_mode.value),
390+
permission=VFolderPermission(input.permission.value),
391+
cloneable=input.cloneable,
392+
)
393+
result = await self._processors.vfolder.create_vfolder_in_project.wait_for_complete(action)
394+
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))
395+
366396
async def create_upload_session(
367397
self, vfolder_id: UUID, input: CreateUploadSessionInput
368398
) -> CreateUploadSessionPayload:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@
415415
bulk_delete_vfolders_v2,
416416
bulk_purge_vfolders_v2,
417417
clone_vfolder_v2,
418+
create_vfolder_in_project,
418419
create_vfolder_v2,
419420
delete_vfolder_v2,
420421
deploy_vfolder_v2,
@@ -841,6 +842,7 @@ class Mutation:
841842
deploy_model_card_v2 = deploy_model_card_v2
842843
# VFolder V2 mutations
843844
create_vfolder_v2 = create_vfolder_v2
845+
create_vfolder_in_project = create_vfolder_in_project
844846
delete_vfolder_v2 = delete_vfolder_v2
845847
purge_vfolder_v2 = purge_vfolder_v2
846848
restore_vfolder_v2 = restore_vfolder_v2

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
bulk_delete_vfolders_v2,
1010
bulk_purge_vfolders_v2,
1111
clone_vfolder_v2,
12+
create_vfolder_in_project,
1213
create_vfolder_v2,
1314
delete_vfolder_v2,
1415
deploy_vfolder_v2,
@@ -48,6 +49,7 @@
4849
# Mutations
4950
"bulk_delete_vfolders_v2",
5051
"bulk_purge_vfolders_v2",
52+
"create_vfolder_in_project",
5153
"create_vfolder_v2",
5254
"delete_vfolder_v2",
5355
"deploy_vfolder_v2",

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
bulk_delete_vfolders_v2,
55
bulk_purge_vfolders_v2,
66
clone_vfolder_v2,
7+
create_vfolder_in_project,
78
create_vfolder_v2,
89
delete_vfolder_v2,
910
deploy_vfolder_v2,
@@ -27,6 +28,7 @@
2728
# Mutations
2829
"bulk_delete_vfolders_v2",
2930
"bulk_purge_vfolders_v2",
31+
"create_vfolder_in_project",
3032
"create_vfolder_v2",
3133
"delete_vfolder_v2",
3234
"deploy_vfolder_v2",

0 commit comments

Comments
 (0)