diff --git a/changes/11139.feature.md b/changes/11139.feature.md new file mode 100644 index 00000000000..fbdd57fef5c --- /dev/null +++ b/changes/11139.feature.md @@ -0,0 +1 @@ +Add project-scoped `createVFolderInProject` GraphQL mutation with RBAC enforcement via ScopeActionProcessor. diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 184a60d363c..49a714e0826 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -3948,6 +3948,28 @@ type CreateUserV2Payload user: UserV2! } +""" +Added in UNRELEASED. Scope-agnostic body for vfolder creation. The owning scope is supplied as a separate mutation argument. +""" +input CreateVFolderInScopeInput + @join__type(graph: STRAWBERRY) +{ + """VFolder name.""" + name: String! + + """Storage host for the vfolder.""" + host: String = null + + """Usage mode of the vfolder.""" + usageMode: VFolderUsageMode! = GENERAL + + """Default mount permission of the vfolder.""" + permission: VFolderMountPermission! = READ_WRITE + + """Whether the vfolder is cloneable.""" + cloneable: Boolean! = false +} + """Added in 26.4.2. Input for creating a new virtual folder.""" input CreateVFolderV2Input @join__type(graph: STRAWBERRY) @@ -10798,6 +10820,11 @@ type Mutation """Added in 26.4.2. Create a new virtual folder.""" createVfolderV2(input: CreateVFolderV2Input!): CreateVFolderV2Payload! @join__field(graph: STRAWBERRY) + """ + Added in UNRELEASED. Create a virtual folder owned by the specified project. Requires project-scoped CREATE permission. + """ + createVFolderInProject(projectId: UUID!, input: CreateVFolderInScopeInput!): CreateVFolderV2Payload! @join__field(graph: STRAWBERRY) + """Added in 26.4.2. Soft-delete a virtual folder (move to trash).""" deleteVfolderV2(vfolderId: UUID!): DeleteVFolderV2Payload! @join__field(graph: STRAWBERRY) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 14509f53ebc..701cf835c97 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -2469,6 +2469,26 @@ type CreateVFSStoragePayload { vfsStorage: VFSStorage! } +""" +Added in UNRELEASED. Scope-agnostic body for vfolder creation. The owning scope is supplied as a separate mutation argument. +""" +input CreateVFolderInScopeInput { + """VFolder name.""" + name: String! + + """Storage host for the vfolder.""" + host: String = null + + """Usage mode of the vfolder.""" + usageMode: VFolderUsageMode! = GENERAL + + """Default mount permission of the vfolder.""" + permission: VFolderMountPermission! = READ_WRITE + + """Whether the vfolder is cloneable.""" + cloneable: Boolean! = false +} + """Added in 26.4.2. Input for creating a new virtual folder.""" input CreateVFolderV2Input { """VFolder name.""" @@ -6788,6 +6808,11 @@ type Mutation { """Added in 26.4.2. Create a new virtual folder.""" createVfolderV2(input: CreateVFolderV2Input!): CreateVFolderV2Payload! + """ + Added in UNRELEASED. Create a virtual folder owned by the specified project. Requires project-scoped CREATE permission. + """ + createVFolderInProject(projectId: UUID!, input: CreateVFolderInScopeInput!): CreateVFolderV2Payload! + """Added in 26.4.2. Soft-delete a virtual folder (move to trash).""" deleteVfolderV2(vfolderId: UUID!): DeleteVFolderV2Payload! diff --git a/src/ai/backend/client/cli/v2/vfolder/commands.py b/src/ai/backend/client/cli/v2/vfolder/commands.py index 8d36e13148d..4fd8eb48dc9 100644 --- a/src/ai/backend/client/cli/v2/vfolder/commands.py +++ b/src/ai/backend/client/cli/v2/vfolder/commands.py @@ -163,6 +163,47 @@ async def _run() -> None: asyncio.run(_run()) +@vfolder.command(name="project-create") +@click.argument("project_id", type=click.UUID) +@click.option("--name", required=True, help="VFolder name.") +@click.option( + "--usage-mode", + default="general", + type=click.Choice(["general", "model", "data"], case_sensitive=False), + help="Usage mode of the vfolder.", +) +@click.option("--host", default=None, type=str, help="Storage host.") +@click.option("--cloneable", is_flag=True, default=False, help="Allow cloning.") +def project_create( + project_id: UUID, + name: str, + usage_mode: str, + host: str | None, + cloneable: bool, +) -> None: + """Create a vfolder owned by a project.""" + + from ai.backend.common.dto.manager.v2.vfolder.request import CreateVFolderInScopeInput + from ai.backend.common.dto.manager.v2.vfolder.types import VFolderUsageMode + + input_dto = CreateVFolderInScopeInput( + name=name, + usage_mode=VFolderUsageMode(usage_mode), + host=host, + cloneable=cloneable, + ) + + async def _run() -> None: + registry = await create_v2_registry(load_v2_config()) + try: + result = await registry.vfolder.create_in_project(project_id, input_dto) + print_result(result) + finally: + await registry.close() + + asyncio.run(_run()) + + @vfolder.command() @click.argument("vfolder_id", type=click.UUID) def delete(vfolder_id: UUID) -> None: diff --git a/src/ai/backend/client/v2/domains_v2/vfolder.py b/src/ai/backend/client/v2/domains_v2/vfolder.py index cbd9f0308d9..2a414528ac9 100644 --- a/src/ai/backend/client/v2/domains_v2/vfolder.py +++ b/src/ai/backend/client/v2/domains_v2/vfolder.py @@ -12,6 +12,7 @@ CreateDownloadSessionInput, CreateUploadSessionInput, CreateVFolderInput, + CreateVFolderInScopeInput, DeleteFilesInput, DeployVFolderInput, ListFilesInput, @@ -78,6 +79,19 @@ async def create(self, request: CreateVFolderInput) -> CreateVFolderPayload: response_model=CreateVFolderPayload, ) + async def create_in_project( + self, + project_id: UUID, + request: CreateVFolderInScopeInput, + ) -> CreateVFolderPayload: + """Create a vfolder owned by ``project_id``.""" + return await self._client.typed_request( + "POST", + f"{_PATH}/projects/{project_id}/create", + request=request, + response_model=CreateVFolderPayload, + ) + async def create_upload_session( self, vfolder_id: UUID, diff --git a/src/ai/backend/common/dto/manager/v2/vfolder/request.py b/src/ai/backend/common/dto/manager/v2/vfolder/request.py index 6a302bf1e3f..b5dbe3bc368 100644 --- a/src/ai/backend/common/dto/manager/v2/vfolder/request.py +++ b/src/ai/backend/common/dto/manager/v2/vfolder/request.py @@ -30,6 +30,7 @@ "CloneVFolderInput", "CreateDownloadSessionInput", "CreateUploadSessionInput", + "CreateVFolderInScopeInput", "CreateVFolderInput", "DeleteFilesInput", "DeleteInvitationInput", @@ -84,6 +85,38 @@ def strip_and_validate_name(cls, v: object) -> object: return v +class CreateVFolderInScopeInput(BaseRequestModel): + """Scope-agnostic body for vfolder creation under a specific scope. + + The owning scope (project, user, domain, …) is supplied externally by + the transport layer (REST path segment, GraphQL mutation argument) + and is NOT part of this body. This keeps the body reusable across + scope-specific endpoints without forcing clients to duplicate the + scope identifier. + """ + + name: VFolderName = Field(description="VFolder name") + host: str | None = Field(default=None, description="Storage host for the vfolder") + usage_mode: VFolderUsageMode = Field( + default=VFolderUsageMode.GENERAL, description="Usage mode of the vfolder" + ) + permission: VFolderPermissionField = Field( + default=VFolderPermissionField.READ_WRITE, + description="Default permission of the vfolder", + ) + cloneable: bool = Field(default=False, description="Whether the vfolder is cloneable") + + @field_validator("name", mode="before") + @classmethod + def strip_and_validate_name(cls, v: object) -> object: + if isinstance(v, str): + stripped = v.strip() + if not stripped: + raise ValueError("name must not be blank or whitespace-only") + return stripped + return v + + class UpdateVFolderInput(BaseRequestModel): """Input for updating a virtual folder.""" diff --git a/src/ai/backend/manager/api/adapters/vfolder.py b/src/ai/backend/manager/api/adapters/vfolder.py index 07eb14757e3..09460ba7cbc 100644 --- a/src/ai/backend/manager/api/adapters/vfolder.py +++ b/src/ai/backend/manager/api/adapters/vfolder.py @@ -16,6 +16,7 @@ CreateDownloadSessionInput, CreateUploadSessionInput, CreateVFolderInput, + CreateVFolderInScopeInput, DeleteFilesInput, DeployVFolderInput, ListFilesInput, @@ -127,6 +128,9 @@ from ai.backend.manager.services.vfolder.actions.upload_session_v2 import ( CreateUploadSessionV2Action, ) +from ai.backend.manager.services.vfolder.actions.vfolder_in_project import ( + CreateVFolderInProjectAction, +) from ai.backend.manager.services.vfolder.actions.vfolder_v2 import ( DeleteVFolderV2Action, PurgeVFolderV2Action, @@ -363,6 +367,32 @@ async def create(self, input: CreateVFolderInput) -> CreateVFolderPayload: result = await self._processors.vfolder.create_vfolder_v2.wait_for_complete(action) return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder)) + async def create_in_project( + self, + project_id: UUID, + input: CreateVFolderInScopeInput, + ) -> CreateVFolderPayload: + """Create a vfolder owned by the given project. + + Uses ``CreateVFolderInProjectAction`` which is PROJECT-scoped so the + caller must hold CREATE permission on the project. + """ + me = current_user() + if me is None: + raise UnreachableError("User context is not available") + action = CreateVFolderInProjectAction( + project_id=project_id, + user_id=me.user_id, + domain_name=me.domain_name, + name=input.name, + host=input.host, + usage_mode=VFolderUsageMode(input.usage_mode.value), + permission=VFolderPermission(input.permission.value), + cloneable=input.cloneable, + ) + result = await self._processors.vfolder.create_vfolder_in_project.wait_for_complete(action) + return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder)) + async def create_upload_session( self, vfolder_id: UUID, input: CreateUploadSessionInput ) -> CreateUploadSessionPayload: diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 81bb1c5ea77..cb467c72cc9 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -415,6 +415,7 @@ bulk_delete_vfolders_v2, bulk_purge_vfolders_v2, clone_vfolder_v2, + create_vfolder_in_project, create_vfolder_v2, delete_vfolder_v2, deploy_vfolder_v2, @@ -841,6 +842,7 @@ class Mutation: deploy_model_card_v2 = deploy_model_card_v2 # VFolder V2 mutations create_vfolder_v2 = create_vfolder_v2 + create_vfolder_in_project = create_vfolder_in_project delete_vfolder_v2 = delete_vfolder_v2 purge_vfolder_v2 = purge_vfolder_v2 restore_vfolder_v2 = restore_vfolder_v2 diff --git a/src/ai/backend/manager/api/gql/vfolder_v2/__init__.py b/src/ai/backend/manager/api/gql/vfolder_v2/__init__.py index b695c3679ed..5efaaac494c 100644 --- a/src/ai/backend/manager/api/gql/vfolder_v2/__init__.py +++ b/src/ai/backend/manager/api/gql/vfolder_v2/__init__.py @@ -9,6 +9,7 @@ bulk_delete_vfolders_v2, bulk_purge_vfolders_v2, clone_vfolder_v2, + create_vfolder_in_project, create_vfolder_v2, delete_vfolder_v2, deploy_vfolder_v2, @@ -48,6 +49,7 @@ # Mutations "bulk_delete_vfolders_v2", "bulk_purge_vfolders_v2", + "create_vfolder_in_project", "create_vfolder_v2", "delete_vfolder_v2", "deploy_vfolder_v2", diff --git a/src/ai/backend/manager/api/gql/vfolder_v2/resolver/__init__.py b/src/ai/backend/manager/api/gql/vfolder_v2/resolver/__init__.py index 681561f835d..15b71dfb032 100644 --- a/src/ai/backend/manager/api/gql/vfolder_v2/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/vfolder_v2/resolver/__init__.py @@ -4,6 +4,7 @@ bulk_delete_vfolders_v2, bulk_purge_vfolders_v2, clone_vfolder_v2, + create_vfolder_in_project, create_vfolder_v2, delete_vfolder_v2, deploy_vfolder_v2, @@ -27,6 +28,7 @@ # Mutations "bulk_delete_vfolders_v2", "bulk_purge_vfolders_v2", + "create_vfolder_in_project", "create_vfolder_v2", "delete_vfolder_v2", "deploy_vfolder_v2", diff --git a/src/ai/backend/manager/api/gql/vfolder_v2/resolver/mutation.py b/src/ai/backend/manager/api/gql/vfolder_v2/resolver/mutation.py index 48a803f2658..dd804fbc6c8 100644 --- a/src/ai/backend/manager/api/gql/vfolder_v2/resolver/mutation.py +++ b/src/ai/backend/manager/api/gql/vfolder_v2/resolver/mutation.py @@ -17,6 +17,7 @@ CloneVFolderInputGQL, CloneVFolderPayloadGQL, CreateVFolderInputGQL, + CreateVFolderInScopeInputGQL, CreateVFolderPayloadGQL, DeleteFilesInputGQL, DeleteFilesPayloadGQL, @@ -245,6 +246,26 @@ async def bulk_delete_vfolders_v2( return BulkDeleteVFoldersPayloadGQL.from_pydantic(payload) +@gql_mutation( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Create a virtual folder owned by the specified project. " + "Requires project-scoped CREATE permission." + ), + ), + name="createVFolderInProject", +) # type: ignore[misc] +async def create_vfolder_in_project( + info: Info[StrawberryGQLContext], + project_id: UUID, + input: CreateVFolderInScopeInputGQL, +) -> CreateVFolderPayloadGQL: + """Create a new virtual folder scoped to a project.""" + payload = await info.context.adapters.vfolder.create_in_project(project_id, input.to_pydantic()) + return CreateVFolderPayloadGQL.from_pydantic(payload) + + @gql_mutation( BackendAIGQLMeta( added_version="26.4.2", diff --git a/src/ai/backend/manager/api/gql/vfolder_v2/types/mutations.py b/src/ai/backend/manager/api/gql/vfolder_v2/types/mutations.py index c6ddd1f4719..d3fd05b067b 100644 --- a/src/ai/backend/manager/api/gql/vfolder_v2/types/mutations.py +++ b/src/ai/backend/manager/api/gql/vfolder_v2/types/mutations.py @@ -22,6 +22,9 @@ from ai.backend.common.dto.manager.v2.vfolder.request import ( CreateVFolderInput as CreateInputDTO, ) +from ai.backend.common.dto.manager.v2.vfolder.request import ( + CreateVFolderInScopeInput as CreateInScopeInputDTO, +) from ai.backend.common.dto.manager.v2.vfolder.request import ( DeleteFilesInput as DeleteFilesInputDTO, ) @@ -94,6 +97,10 @@ PresetDeploymentStrategyInputGQL, ) from ai.backend.manager.api.gql.pydantic_compat import PydanticOutputMixin +from ai.backend.manager.api.gql.vfolder_v2.types.enum import ( + VFolderMountPermissionGQL, + VFolderUsageModeGQL, +) from ai.backend.manager.api.gql.vfolder_v2.types.node import VFolderGQL # ============================================================ @@ -123,6 +130,29 @@ class CreateVFolderInputGQL(PydanticInputMixin[CreateInputDTO]): cloneable: bool = gql_field(default=False, description="Whether the vfolder is cloneable.") +@gql_pydantic_input( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Scope-agnostic body for vfolder creation. The owning scope is " + "supplied as a separate mutation argument." + ), + ), + name="CreateVFolderInScopeInput", +) +class CreateVFolderInScopeInputGQL(PydanticInputMixin[CreateInScopeInputDTO]): + name: str = gql_field(description="VFolder name.") + host: str | None = gql_field(default=None, description="Storage host for the vfolder.") + usage_mode: VFolderUsageModeGQL = gql_field( + default=VFolderUsageModeGQL.GENERAL, description="Usage mode of the vfolder." + ) + permission: VFolderMountPermissionGQL = gql_field( + default=VFolderMountPermissionGQL.READ_WRITE, + description="Default mount permission of the vfolder.", + ) + cloneable: bool = gql_field(default=False, description="Whether the vfolder is cloneable.") + + @gql_pydantic_input( BackendAIGQLMeta( added_version="26.4.2", diff --git a/src/ai/backend/manager/api/rest/v2/vfolder/handler.py b/src/ai/backend/manager/api/rest/v2/vfolder/handler.py index e38a34fb1f2..096e0edf92d 100644 --- a/src/ai/backend/manager/api/rest/v2/vfolder/handler.py +++ b/src/ai/backend/manager/api/rest/v2/vfolder/handler.py @@ -13,6 +13,7 @@ CreateDownloadSessionInput, CreateUploadSessionInput, CreateVFolderInput, + CreateVFolderInScopeInput, DeleteFilesInput, DeployVFolderInput, ListFilesInput, @@ -106,6 +107,15 @@ async def restore( result = await self._adapter.restore(path.parsed.vfolder_id) return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + async def project_create( + self, + path: PathParam[ProjectIdPathParam], + body: BodyParam[CreateVFolderInScopeInput], + ) -> APIResponse: + """Create a vfolder owned by a project. Scope comes from the URL path.""" + result = await self._adapter.create_in_project(path.parsed.project_id, body.parsed) + return APIResponse.build(status_code=HTTPStatus.CREATED, response_model=result) + async def deploy( self, path: PathParam[VFolderIdPathParam], diff --git a/src/ai/backend/manager/api/rest/v2/vfolder/registry.py b/src/ai/backend/manager/api/rest/v2/vfolder/registry.py index 81fc4173e54..7edff161b41 100644 --- a/src/ai/backend/manager/api/rest/v2/vfolder/registry.py +++ b/src/ai/backend/manager/api/rest/v2/vfolder/registry.py @@ -116,6 +116,12 @@ def register_v2_vfolder_routes( handler.clone, middlewares=[auth_required], ) + registry.add( + "POST", + "/projects/{project_id}/create", + handler.project_create, + middlewares=[auth_required], + ) registry.add( "POST", "/delete", diff --git a/src/ai/backend/manager/data/group/types.py b/src/ai/backend/manager/data/group/types.py index 13bfc0031fe..48635bdce96 100644 --- a/src/ai/backend/manager/data/group/types.py +++ b/src/ai/backend/manager/data/group/types.py @@ -74,6 +74,14 @@ def entity_operations(self) -> Mapping[RBACElementType, Iterable[OperationType]] return operations +@dataclass(frozen=True) +class ProjectResourceInfo: + project_id: uuid.UUID + max_vfolder_count: int + max_quota_scope_size: int + project_type: ProjectType + + @dataclass(frozen=True) class ProjectMemberRoleSpec: """ScopeSystemRoleData implementation for the project-scoped member role.""" diff --git a/src/ai/backend/manager/repositories/vfolder/repository.py b/src/ai/backend/manager/repositories/vfolder/repository.py index 490ad973b68..96417c7d7e0 100644 --- a/src/ai/backend/manager/repositories/vfolder/repository.py +++ b/src/ai/backend/manager/repositories/vfolder/repository.py @@ -23,6 +23,7 @@ VFolderID, ) from ai.backend.manager.data.agent.types import AgentStatus +from ai.backend.manager.data.group.types import ProjectResourceInfo from ai.backend.manager.data.kernel.types import KernelStatus from ai.backend.manager.data.permission.id import ObjectId, ScopeId from ai.backend.manager.data.permission.types import ( @@ -60,7 +61,7 @@ ) from ai.backend.manager.errors.user import UserNotFound from ai.backend.manager.models.agent import agents -from ai.backend.manager.models.group import GroupRow, ProjectType +from ai.backend.manager.models.group import GroupRow from ai.backend.manager.models.group import association_groups_users as agus from ai.backend.manager.models.kernel import kernels from ai.backend.manager.models.keypair import KeyPairRow, keypairs @@ -806,11 +807,8 @@ async def get_users_by_ids(self, user_ids: list[uuid.UUID]) -> list[tuple[uuid.U @vfolder_repository_resilience.apply() async def get_group_resource_info( self, group_id_or_name: str | uuid.UUID, domain_name: str - ) -> tuple[uuid.UUID, int, int, ProjectType] | None: - """ - Get group resource information by group ID or name. - Returns (group_uuid, max_vfolder_count, max_quota_scope_size, group_type) or None. - """ + ) -> ProjectResourceInfo | None: + """Get group resource information by group ID or name.""" async with self._db.begin_readonly_session_read_committed() as session: if isinstance(group_id_or_name, str): @@ -836,11 +834,11 @@ async def get_group_resource_info( if not group_row: return None - return ( - group_row.id, - group_row.resource_policy_row.max_vfolder_count, - group_row.resource_policy_row.max_quota_scope_size, - group_row.type, + return ProjectResourceInfo( + project_id=group_row.id, + max_vfolder_count=group_row.resource_policy_row.max_vfolder_count, + max_quota_scope_size=group_row.resource_policy_row.max_quota_scope_size, + project_type=group_row.type, ) @vfolder_repository_resilience.apply() diff --git a/src/ai/backend/manager/services/vfolder/actions/vfolder_in_project.py b/src/ai/backend/manager/services/vfolder/actions/vfolder_in_project.py new file mode 100644 index 00000000000..3d23e21e57a --- /dev/null +++ b/src/ai/backend/manager/services/vfolder/actions/vfolder_in_project.py @@ -0,0 +1,82 @@ +"""RBAC-enforced v2 VFolder actions. + +Create is PROJECT-scoped (entity doesn't exist yet) and uses +``ScopeActionProcessor`` with ``scope_rbac_validators``. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.types import VFolderUsageMode +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.data.vfolder.types import VFolderData +from ai.backend.manager.models.vfolder import VFolderPermission +from ai.backend.manager.services.vfolder.actions.base import ( + VFolderScopeAction, + VFolderScopeActionResult, +) + +# --------------------------------------------------------------------------- +# Create (scope action — entity does not exist yet, requires project_id) +# --------------------------------------------------------------------------- + + +@dataclass +class CreateVFolderInProjectAction(VFolderScopeAction): + """Create a vfolder owned by a specific project.""" + + project_id: uuid.UUID + user_id: uuid.UUID + domain_name: str + name: str + host: str | None + usage_mode: VFolderUsageMode + permission: VFolderPermission + cloneable: bool + + @override + def entity_id(self) -> str | None: + return None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.CREATE + + @override + def scope_type(self) -> ScopeType: + return ScopeType.PROJECT + + @override + def scope_id(self) -> str: + return str(self.project_id) + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef( + element_type=RBACElementType.PROJECT, + element_id=str(self.project_id), + ) + + +@dataclass +class CreateVFolderInProjectActionResult(VFolderScopeActionResult): + project_id: uuid.UUID + vfolder: VFolderData + + @override + def entity_id(self) -> str | None: + return str(self.vfolder.id) + + @override + def scope_type(self) -> ScopeType: + return ScopeType.PROJECT + + @override + def scope_id(self) -> str: + return str(self.project_id) diff --git a/src/ai/backend/manager/services/vfolder/processors/vfolder.py b/src/ai/backend/manager/services/vfolder/processors/vfolder.py index 4bf24d61ee4..7714b224dcf 100644 --- a/src/ai/backend/manager/services/vfolder/processors/vfolder.py +++ b/src/ai/backend/manager/services/vfolder/processors/vfolder.py @@ -92,6 +92,10 @@ CreateUploadSessionV2Action, CreateUploadSessionV2ActionResult, ) +from ai.backend.manager.services.vfolder.actions.vfolder_in_project import ( + CreateVFolderInProjectAction, + CreateVFolderInProjectActionResult, +) from ai.backend.manager.services.vfolder.actions.vfolder_v2 import ( DeleteVFolderV2Action, DeleteVFolderV2ActionResult, @@ -163,6 +167,9 @@ class VFolderProcessors(AbstractProcessorPackage): delete_v2: SingleEntityActionProcessor[DeleteVFolderV2Action, DeleteVFolderV2ActionResult] purge_v2: SingleEntityActionProcessor[PurgeVFolderV2Action, PurgeVFolderV2ActionResult] clone_v2: ActionProcessor[CloneVFolderV2Action, CloneVFolderV2ActionResult] + create_vfolder_in_project: ScopeActionProcessor[ + CreateVFolderInProjectAction, CreateVFolderInProjectActionResult + ] def __init__( self, @@ -261,6 +268,11 @@ def __init__( service.purge_v2, action_monitors, validators=single_entity_rbac_validators ) self.clone_v2 = ActionProcessor(service.clone_v2, action_monitors) + self.create_vfolder_in_project = ScopeActionProcessor( + service.create_in_project, + action_monitors, + validators=scope_rbac_validators, + ) @override def supported_actions(self) -> list[ActionSpec]: @@ -300,4 +312,5 @@ def supported_actions(self) -> list[ActionSpec]: DeleteVFolderV2Action.spec(), PurgeVFolderV2Action.spec(), CloneVFolderV2Action.spec(), + CreateVFolderInProjectAction.spec(), ] diff --git a/src/ai/backend/manager/services/vfolder/services/vfolder.py b/src/ai/backend/manager/services/vfolder/services/vfolder.py index 9a0f03d089d..6f8e6dcd896 100644 --- a/src/ai/backend/manager/services/vfolder/services/vfolder.py +++ b/src/ai/backend/manager/services/vfolder/services/vfolder.py @@ -2,6 +2,7 @@ import logging import math import uuid +from collections.abc import Sequence from pathlib import Path, PurePosixPath from typing import ( Any, @@ -30,6 +31,7 @@ ) from ai.backend.logging.utils import BraceStyleAdapter from ai.backend.manager.config.provider import ManagerConfigProvider +from ai.backend.manager.data.group.types import ProjectResourceInfo from ai.backend.manager.data.vfolder.dto import UserIdentity from ai.backend.manager.data.vfolder.types import ( VFolderCreateParams, @@ -158,6 +160,10 @@ CreateUploadSessionV2Action, CreateUploadSessionV2ActionResult, ) +from ai.backend.manager.services.vfolder.actions.vfolder_in_project import ( + CreateVFolderInProjectAction, + CreateVFolderInProjectActionResult, +) from ai.backend.manager.services.vfolder.actions.vfolder_v2 import ( DeleteVFolderV2Action, DeleteVFolderV2ActionResult, @@ -269,7 +275,10 @@ async def create(self, action: CreateVFolderAction) -> CreateVFolderActionResult ) if not group_info: raise ProjectNotFound(f"Project with {group_id_or_name} not found.") - group_uuid, max_vfolder_count, max_quota_scope_size, group_type = group_info + group_uuid = group_info.project_id + max_vfolder_count = group_info.max_vfolder_count + max_quota_scope_size = group_info.max_quota_scope_size + group_type = group_info.project_type container_uid = None case None: user_info = await self._vfolder_repository.get_user_resource_info(user_uuid) @@ -1432,34 +1441,125 @@ async def get_accessible_vfolder( await _check_vfolder_status(row["status"], action.required_status) return GetAccessibleVFolderActionResult(row=row) - async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2ActionResult: - """Create a new vfolder (v2). Resolves policy internally from user_id.""" - user_uuid = action.user_id - domain_name = action.domain_name - project_id = action.project_id - - # Resolve user info from DB + async def _load_user(self, user_uuid: uuid.UUID) -> tuple[str, UserRole]: + """Load user and return ``(email, role)``. Raises if the record is incomplete.""" user = await self._user_repository.get_user_by_uuid(user_uuid) if user.role is None or user.domain_name is None: raise ObjectNotFound(object_name="User") - user_role = user.role + return user.email, user.role - # Resolve host - folder_host = action.host - if not folder_host: - folder_host = self._config_provider.config.volumes.default_host - if not folder_host: - raise VFolderInvalidParameter( - "You must specify the vfolder host because the default host is not configured." - ) + def _check_user_role_for_group( + self, user_role: UserRole, group_type: ProjectType | None + ) -> None: + """Legacy gate: non-admin users may only create group vfolders in MODEL_STORE projects.""" + if ( + user_role not in (UserRole.SUPERADMIN, UserRole.ADMIN) + and group_type != ProjectType.MODEL_STORE + ): + raise Forbidden("no permission") + async def _check_ownership_allowed(self, ownership_type: str) -> Sequence[str]: + """Ensure the cluster allows this ownership_type. Returns the allowed list.""" allowed_vfolder_types = ( await self._config_provider.legacy_etcd_config_loader.get_vfolder_types() ) + if ownership_type not in allowed_vfolder_types: + raise VFolderInvalidParameter( + f"{ownership_type}-owned vfolder is not allowed in this cluster" + ) + return list(allowed_vfolder_types) - if action.name.startswith(".") and action.name != ".local": - if project_id is not None: - raise VFolderInvalidParameter("dot-prefixed vfolders cannot be a group folder.") + async def _check_name_uniqueness( + self, + name: str, + user_uuid: uuid.UUID, + user_role: UserRole, + domain_name: str, + allowed_types: Sequence[str], + ) -> None: + """Raise VFolderAlreadyExists if the user already owns a vfolder with this name.""" + name_exists = await self._vfolder_repository.check_vfolder_name_exists( + name, user_uuid, user_role, domain_name, list(allowed_types) + ) + if name_exists: + raise VFolderAlreadyExists(f"VFolder with the given name already exists. ({name})") + + async def _resolve_host(self, requested_host: str | None) -> str: + """Return the target storage host, falling back to the configured default.""" + host = requested_host + if not host: + host = self._config_provider.config.volumes.default_host + if not host: + raise VFolderInvalidParameter( + "You must specify the vfolder host because the default host is not configured." + ) + return host + + def _check_name_parameter(self, name: str, is_group: bool) -> None: + """Dot-prefixed names (except ``.local``) cannot be used for group-owned vfolders.""" + if name.startswith(".") and name != ".local" and is_group: + raise VFolderInvalidParameter("dot-prefixed vfolders cannot be a group folder.") + + async def _resolve_project_info( + self, project_id: uuid.UUID, domain_name: str + ) -> ProjectResourceInfo: + """Fetch project resource info for vfolder creation.""" + group_info = await self._vfolder_repository.get_group_resource_info(project_id, domain_name) + if not group_info: + raise ProjectNotFound(f"Project with {project_id} not found.") + return group_info + + async def _check_user_vfolder_quota(self, user_uuid: uuid.UUID, max_count: int) -> None: + """Enforce per-user vfolder count quota (no-op when ``max_count <= 0``).""" + if max_count <= 0: + return + current = await self._vfolder_repository.count_vfolders_by_user(user_uuid) + if current >= max_count: + raise VFolderInvalidParameter("You cannot create more vfolders.") + + async def _check_group_vfolder_quota(self, group_uuid: uuid.UUID, max_count: int) -> None: + """Enforce per-group vfolder count quota (no-op when ``max_count <= 0``).""" + if max_count <= 0: + return + current = await self._vfolder_repository.count_vfolders_by_group(group_uuid) + if current >= max_count: + raise VFolderInvalidParameter("You cannot create more vfolders.") + + def _determine_ownership( + self, + user_uuid: uuid.UUID, + group_uuid: uuid.UUID | None, + user_role: UserRole, + group_type: ProjectType | None, + ) -> tuple[str, QuotaScopeID]: + """Decide ``(ownership_type, quota_scope_id)`` for create_v2. + + When ``group_uuid`` is set, gates non-admin users from group ownership + (except MODEL_STORE) via the legacy UserRole check. + """ + if group_uuid is not None: + self._check_user_role_for_group(user_role, group_type) + return "group", QuotaScopeID(QuotaScopeType.PROJECT, group_uuid) + return "user", QuotaScopeID(QuotaScopeType.USER, user_uuid) + + def _check_model_store_usage_mode( + self, group_type: ProjectType | None, usage_mode: VFolderUsageMode + ) -> None: + """Ensure ``usage_mode`` is MODEL when the project is a MODEL_STORE.""" + if group_type == ProjectType.MODEL_STORE and usage_mode != VFolderUsageMode.MODEL: + raise VFolderInvalidParameter( + "Only Model VFolder can be created under the model store project" + ) + + async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2ActionResult: + """Create a new vfolder (v2). Resolves policy internally from user_id.""" + user_uuid = action.user_id + domain_name = action.domain_name + project_id = action.project_id + + email, user_role = await self._load_user(user_uuid) + folder_host = await self._resolve_host(action.host) + self._check_name_parameter(action.name, is_group=project_id is not None) group_uuid: uuid.UUID | None = None group_type: ProjectType | None = None @@ -1468,12 +1568,11 @@ async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2Actio container_uid: int | None = None if project_id is not None: - group_info = await self._vfolder_repository.get_group_resource_info( - project_id, domain_name - ) - if not group_info: - raise ProjectNotFound(f"Project with {project_id} not found.") - group_uuid, max_vfolder_count, max_quota_scope_size, group_type = group_info + project_info = await self._resolve_project_info(project_id, domain_name) + group_uuid = project_info.project_id + max_vfolder_count = project_info.max_vfolder_count + max_quota_scope_size = project_info.max_quota_scope_size + group_type = project_info.project_type container_uid = None else: user_info = await self._vfolder_repository.get_user_resource_info(user_uuid) @@ -1485,28 +1584,11 @@ async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2Actio VFOLDER_GROUP_PERMISSION_MODE if container_uid is not None else None ) - # Determine ownership - if group_uuid is not None: - ownership_type = "group" - quota_scope_id = QuotaScopeID(QuotaScopeType.PROJECT, group_uuid) - if ( - user_role not in (UserRole.SUPERADMIN, UserRole.ADMIN) - and group_type != ProjectType.MODEL_STORE - ): - raise Forbidden("no permission") - else: - ownership_type = "user" - quota_scope_id = QuotaScopeID(QuotaScopeType.USER, user_uuid) - if ownership_type not in allowed_vfolder_types: - raise VFolderInvalidParameter( - f"{ownership_type}-owned vfolder is not allowed in this cluster" - ) - - if group_type == ProjectType.MODEL_STORE: - if action.usage_mode != VFolderUsageMode.MODEL: - raise VFolderInvalidParameter( - "Only Model VFolder can be created under the model store project" - ) + ownership_type, quota_scope_id = self._determine_ownership( + user_uuid, group_uuid, user_role, group_type + ) + allowed_types = await self._check_ownership_allowed(ownership_type) + self._check_model_store_usage_mode(group_type, action.usage_mode) # Host permission check — resolved from user_id, not passed resource_policy await self._vfolder_repository.ensure_host_permission_allowed_by_user( @@ -1516,43 +1598,29 @@ async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2Actio group_id=group_uuid, ) - # Quota check - if max_vfolder_count > 0: - if ownership_type == "user": - current_count = await self._vfolder_repository.count_vfolders_by_user(user_uuid) - else: - if group_uuid is None: - raise VFolderInvalidParameter("Group UUID is required for group-owned vfolders") - current_count = await self._vfolder_repository.count_vfolders_by_group(group_uuid) - if current_count >= max_vfolder_count: - raise VFolderInvalidParameter("You cannot create more vfolders.") + if ownership_type == "user": + await self._check_user_vfolder_quota(user_uuid, max_vfolder_count) + else: + if group_uuid is None: + raise VFolderInvalidParameter("Group UUID is required for group-owned vfolders") + await self._check_group_vfolder_quota(group_uuid, max_vfolder_count) - # Name uniqueness check - name_exists = await self._vfolder_repository.check_vfolder_name_exists( - action.name, user_uuid, user_role, domain_name, list(allowed_vfolder_types) + await self._check_name_uniqueness( + action.name, user_uuid, user_role, domain_name, allowed_types ) - if name_exists: - raise VFolderAlreadyExists( - f"VFolder with the given name already exists. ({action.name})" - ) - - # Create in storage - folder_id = uuid.uuid4() - try: - vfid = VFolderID(quota_scope_id, folder_id) - proxy_name, volume_name = self._storage_manager.get_proxy_and_volume(folder_host, False) - manager_client = self._storage_manager.get_manager_facing_client(proxy_name) - await manager_client.create_folder( - volume_name, str(vfid), max_quota_scope_size, vfolder_permission_mode - ) - except aiohttp.ClientResponseError as e: - raise VFolderCreationFailure from e mount_permission = action.permission if group_type == ProjectType.MODEL_STORE: mount_permission = VFolderPermission.READ_ONLY - # Create in DB + folder_id = uuid.uuid4() + vfid = VFolderID(quota_scope_id, folder_id) + proxy_name, volume_name = self._storage_manager.get_proxy_and_volume(folder_host, False) + manager_client = self._storage_manager.get_manager_facing_client(proxy_name) + await manager_client.create_folder( + volume_name, str(vfid), max_quota_scope_size, vfolder_permission_mode + ) + params = VFolderCreateParams( id=folder_id, name=action.name, @@ -1561,7 +1629,7 @@ async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2Actio usage_mode=action.usage_mode, permission=mount_permission, host=folder_host, - creator=user.email, + creator=email, creator_id=user_uuid, ownership_type=VFolderOwnershipType(ownership_type), user=user_uuid if ownership_type == "user" else None, @@ -1570,18 +1638,8 @@ async def create_v2(self, action: CreateVFolderV2Action) -> CreateVFolderV2Actio cloneable=action.cloneable, status=VFolderOperationStatus.READY, ) - - try: - create_owner_permission = group_type == ProjectType.MODEL_STORE - await self._vfolder_repository.create_vfolder_with_permission( - params, create_owner_permission=create_owner_permission - ) - except sa_exc.DataError as e: - raise VFolderInvalidParameter from e - - # Fetch created vfolder data for response - vfolder_data = await self._vfolder_repository.get_by_id_validated( - folder_id, user_uuid, domain_name + vfolder_data = await self._vfolder_repository.create_vfolder_with_permission( + params, create_owner_permission=(group_type == ProjectType.MODEL_STORE) ) return CreateVFolderV2ActionResult(vfolder=vfolder_data) @@ -1627,6 +1685,82 @@ async def get_v2(self, action: GetVFolderV2Action) -> GetVFolderV2ActionResult: vfolder_data = await self._vfolder_repository.get_by_id(action.vfolder_uuid) return GetVFolderV2ActionResult(vfolder=vfolder_data) + async def create_in_project( + self, action: CreateVFolderInProjectAction + ) -> CreateVFolderInProjectActionResult: + """Create a vfolder owned by a project. + + RBAC is enforced by the ScopeActionProcessor at the processor level. + Unlike ``create_v2`` this method does NOT check ``UserRole`` — project + CREATE permission is validated by the scope RBAC validator. + """ + user_uuid = action.user_id + domain_name = action.domain_name + project_id = action.project_id + + email, user_role = await self._load_user(user_uuid) + folder_host = await self._resolve_host(action.host) + self._check_name_parameter(action.name, is_group=True) + + project_info = await self._resolve_project_info(project_id, domain_name) + group_uuid = project_info.project_id + max_vfolder_count = project_info.max_vfolder_count + max_quota_scope_size = project_info.max_quota_scope_size + group_type = project_info.project_type + quota_scope_id = QuotaScopeID(QuotaScopeType.PROJECT, group_uuid) + + allowed_types = await self._check_ownership_allowed("group") + self._check_model_store_usage_mode(group_type, action.usage_mode) + + # Host permission check + await self._vfolder_repository.ensure_host_permission_allowed_by_user( + folder_host, + permission=VFolderHostPermission.CREATE, + user_uuid=user_uuid, + group_id=group_uuid, + ) + + await self._check_group_vfolder_quota(group_uuid, max_vfolder_count) + + await self._check_name_uniqueness( + action.name, user_uuid, user_role, domain_name, allowed_types + ) + + mount_permission = action.permission + if group_type == ProjectType.MODEL_STORE: + mount_permission = VFolderPermission.READ_ONLY + + folder_id = uuid.uuid4() + vfid = VFolderID(quota_scope_id, folder_id) + proxy_name, volume_name = self._storage_manager.get_proxy_and_volume(folder_host, False) + manager_client = self._storage_manager.get_manager_facing_client(proxy_name) + await manager_client.create_folder(volume_name, str(vfid), max_quota_scope_size, None) + + params = VFolderCreateParams( + id=folder_id, + name=action.name, + domain_name=domain_name, + quota_scope_id=str(quota_scope_id), + usage_mode=action.usage_mode, + permission=mount_permission, + host=folder_host, + creator=email, + creator_id=user_uuid, + ownership_type=VFolderOwnershipType.GROUP, + user=None, + group=group_uuid, + unmanaged_path=None, + cloneable=action.cloneable, + status=VFolderOperationStatus.READY, + ) + vfolder_data = await self._vfolder_repository.create_vfolder_with_permission( + params, create_owner_permission=(group_type == ProjectType.MODEL_STORE) + ) + return CreateVFolderInProjectActionResult( + project_id=action.project_id, + vfolder=vfolder_data, + ) + async def delete_v2(self, action: DeleteVFolderV2Action) -> DeleteVFolderV2ActionResult: """Soft-delete a vfolder by ID. RBAC enforced at processor level.""" me = current_user() diff --git a/tests/component/vfolder_v2/test_vfolder_mutation.py b/tests/component/vfolder_v2/test_vfolder_mutation.py index 129d5a9f0e1..95ab1ea1d30 100644 --- a/tests/component/vfolder_v2/test_vfolder_mutation.py +++ b/tests/component/vfolder_v2/test_vfolder_mutation.py @@ -11,16 +11,25 @@ from collections.abc import AsyncIterator from dataclasses import dataclass from typing import TYPE_CHECKING -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest +import sqlalchemy as sa import yarl from ai.backend.client.v2.auth import HMACAuth from ai.backend.client.v2.config import ClientConfig from ai.backend.client.v2.exceptions import PermissionDeniedError from ai.backend.client.v2.v2_registry import V2ClientRegistry -from ai.backend.common.types import QuotaScopeID, QuotaScopeType, VFolderUsageMode +from ai.backend.common.dto.manager.field import VFolderPermissionField +from ai.backend.common.dto.manager.v2.vfolder.request import CreateVFolderInScopeInput +from ai.backend.common.types import ( + QuotaScopeID, + QuotaScopeType, + VFolderHostPermission, + VFolderHostPermissionMap, + VFolderUsageMode, +) from ai.backend.manager.actions.validators import ActionValidators from ai.backend.manager.actions.validators.rbac import RBACValidators from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator @@ -32,11 +41,17 @@ from ai.backend.manager.api.rest.types import RouteDeps from ai.backend.manager.api.rest.v2.vfolder.handler import V2VFolderHandler from ai.backend.manager.api.rest.v2.vfolder.registry import register_v2_vfolder_routes +from ai.backend.manager.data.permission.status import RoleStatus +from ai.backend.manager.data.permission.types import EntityType, OperationType, ScopeType from ai.backend.manager.data.vfolder.types import ( VFolderMountPermission, VFolderOperationStatus, VFolderOwnershipType, ) +from ai.backend.manager.models.group import groups +from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow +from ai.backend.manager.models.rbac_models.role import RoleRow +from ai.backend.manager.models.rbac_models.user_role import UserRoleRow from ai.backend.manager.models.utils import ExtendedAsyncSAEngine from ai.backend.manager.models.vfolder import vfolders from ai.backend.manager.repositories.permission_controller.repository import ( @@ -74,13 +89,27 @@ def vfolder_processors( database_engine: ExtendedAsyncSAEngine, rbac_permission_repo: PermissionControllerRepository, ) -> VFolderProcessors: - """Override: real scope + single-entity RBAC validators.""" + """Override: real scope + single-entity RBAC validators. + + Storage-proxy calls (``get_proxy_and_volume`` / ``create_folder``) and + the etcd-backed allowed-vfolder-types lookup are stubbed so that + service-layer create paths can complete without external dependencies. + """ vfolder_repository = VfolderRepository(database_engine) user_repository = UserRepository(database_engine) + config_provider = MagicMock() + config_provider.legacy_etcd_config_loader.get_vfolder_types = AsyncMock( + return_value=["user", "group"] + ) + storage_manager = MagicMock() + storage_manager.get_proxy_and_volume.return_value = ("local", "local") + manager_client = MagicMock() + manager_client.create_folder = AsyncMock(return_value=None) + storage_manager.get_manager_facing_client.return_value = manager_client service = VFolderService( - config_provider=MagicMock(), + config_provider=config_provider, etcd=MagicMock(), - storage_manager=MagicMock(), + storage_manager=storage_manager, background_task_manager=MagicMock(), vfolder_repository=vfolder_repository, user_repository=user_repository, @@ -149,6 +178,88 @@ async def user_v2_registry( await registry.close() +@pytest.fixture() +async def project_host_permission_fixture( + db_engine: SAEngine, + group_fixture: uuid.UUID, + vfolder_host_permission_fixture: None, +) -> AsyncIterator[None]: + """Grant all VFolderHostPermission flags on the 'local' host to the test project. + + The base ``vfolder_host_permission_fixture`` covers the domain and keypair + resource policy, but group-owned creates read from the project row itself. + """ + all_perms: set[VFolderHostPermission] = set(VFolderHostPermission) + host_perms = VFolderHostPermissionMap({"local": all_perms}) + async with db_engine.begin() as conn: + await conn.execute( + groups.update() + .where(groups.c.id == group_fixture) + .values(allowed_vfolder_hosts=host_perms) + ) + yield + async with db_engine.begin() as conn: + await conn.execute( + groups.update() + .where(groups.c.id == group_fixture) + .values(allowed_vfolder_hosts=VFolderHostPermissionMap()) + ) + + +@pytest.fixture() +async def regular_user_vfolder_create_permission( + db_engine: SAEngine, + regular_user_fixture: UserFixtureData, + group_fixture: uuid.UUID, +) -> AsyncIterator[None]: + """Grant PROJECT-scoped VFOLDER:CREATE permission to the regular user.""" + role_id = uuid.uuid4() + async with db_engine.begin() as conn: + await conn.execute( + sa.insert(RoleRow.__table__).values( + id=role_id, + name=f"test-vfolder-creator-{secrets.token_hex(4)}", + status=RoleStatus.ACTIVE, + ) + ) + await conn.execute( + sa.insert(UserRoleRow.__table__).values( + user_id=regular_user_fixture.user_uuid, + role_id=role_id, + ) + ) + await conn.execute( + sa.insert(PermissionRow.__table__).values( + role_id=role_id, + scope_type=ScopeType.PROJECT, + scope_id=str(group_fixture), + entity_type=EntityType.VFOLDER, + operation=OperationType.CREATE, + ) + ) + yield + async with db_engine.begin() as conn: + await conn.execute( + PermissionRow.__table__.delete().where(PermissionRow.__table__.c.role_id == role_id) + ) + await conn.execute( + UserRoleRow.__table__.delete().where(UserRoleRow.__table__.c.role_id == role_id) + ) + await conn.execute(RoleRow.__table__.delete().where(RoleRow.__table__.c.id == role_id)) + + +@pytest.fixture() +async def created_vfolder_cleanup( + db_engine: SAEngine, +) -> AsyncIterator[list[uuid.UUID]]: + """Collect vfolder IDs created during a test and delete them on teardown.""" + ids: list[uuid.UUID] = [] + yield ids + if ids: + async with db_engine.begin() as conn: + await conn.execute(vfolders.delete().where(vfolders.c.id.in_(ids))) + + @pytest.fixture() async def project_vfolder( db_engine: SAEngine, @@ -237,3 +348,63 @@ async def test_regular_user_denied( ) -> None: with pytest.raises(PermissionDeniedError): await user_v2_registry.vfolder.purge(project_vfolder.id) + + +class TestCreateVFolderInProjectRBAC: + """POST /v2/vfolders/projects/{project_id}/create -- ScopeActionProcessor RBAC.""" + + async def test_regular_user_denied( + self, + user_v2_registry: V2ClientRegistry, + group_fixture: uuid.UUID, + vfolder_host_permission_fixture: None, + ) -> None: + """Regular user without project CREATE permission is denied before service runs.""" + request = CreateVFolderInScopeInput( + name=f"rbac-denied-{secrets.token_hex(4)}", + host="local", + usage_mode=VFolderUsageMode.GENERAL, + permission=VFolderPermissionField.READ_WRITE, + cloneable=False, + ) + with pytest.raises(PermissionDeniedError): + await user_v2_registry.vfolder.create_in_project(group_fixture, request) + + async def test_superadmin_succeeds( + self, + admin_v2_registry: V2ClientRegistry, + group_fixture: uuid.UUID, + project_host_permission_fixture: None, + created_vfolder_cleanup: list[uuid.UUID], + ) -> None: + """Superadmin bypasses scope RBAC and can create a project-owned vfolder.""" + request = CreateVFolderInScopeInput( + name=f"rbac-admin-{secrets.token_hex(4)}", + host="local", + usage_mode=VFolderUsageMode.GENERAL, + permission=VFolderPermissionField.READ_WRITE, + cloneable=False, + ) + payload = await admin_v2_registry.vfolder.create_in_project(group_fixture, request) + created_vfolder_cleanup.append(payload.vfolder.id) + assert payload.vfolder.ownership.project_id == group_fixture + + async def test_regular_user_with_permission_succeeds( + self, + user_v2_registry: V2ClientRegistry, + group_fixture: uuid.UUID, + project_host_permission_fixture: None, + regular_user_vfolder_create_permission: None, + created_vfolder_cleanup: list[uuid.UUID], + ) -> None: + """Regular user granted PROJECT-scoped VFOLDER:CREATE succeeds.""" + request = CreateVFolderInScopeInput( + name=f"rbac-user-{secrets.token_hex(4)}", + host="local", + usage_mode=VFolderUsageMode.GENERAL, + permission=VFolderPermissionField.READ_WRITE, + cloneable=False, + ) + payload = await user_v2_registry.vfolder.create_in_project(group_fixture, request) + created_vfolder_cleanup.append(payload.vfolder.id) + assert payload.vfolder.ownership.project_id == group_fixture diff --git a/tests/unit/manager/services/vfolder/test_vfolder_crud_service.py b/tests/unit/manager/services/vfolder/test_vfolder_crud_service.py index 210582c41a4..9518ac14b42 100644 --- a/tests/unit/manager/services/vfolder/test_vfolder_crud_service.py +++ b/tests/unit/manager/services/vfolder/test_vfolder_crud_service.py @@ -17,6 +17,7 @@ QuotaScopeType, VFolderUsageMode, ) +from ai.backend.manager.data.group.types import ProjectResourceInfo from ai.backend.manager.data.vfolder.dto import UserIdentity from ai.backend.manager.data.vfolder.types import ( VFolderAccessInfo, @@ -243,7 +244,12 @@ async def test_group_ownership_sets_project_quota_scope( group_uuid: uuid.UUID, ) -> None: mock_vfolder_repository.get_group_resource_info = AsyncMock( - return_value=(group_uuid, 10, 0, None) + return_value=ProjectResourceInfo( + project_id=group_uuid, + max_vfolder_count=10, + max_quota_scope_size=0, + project_type=ProjectType.GENERAL, + ) ) mock_vfolder_repository.ensure_host_permission_allowed = AsyncMock() mock_vfolder_repository.count_vfolders_by_group = AsyncMock(return_value=0) @@ -396,7 +402,12 @@ async def test_model_usage_in_non_model_store_raises_invalid_parameter( group_uuid: uuid.UUID, ) -> None: mock_vfolder_repository.get_group_resource_info = AsyncMock( - return_value=(group_uuid, 10, 0, ProjectType.MODEL_STORE) + return_value=ProjectResourceInfo( + project_id=group_uuid, + max_vfolder_count=10, + max_quota_scope_size=0, + project_type=ProjectType.MODEL_STORE, + ) ) mock_vfolder_repository.ensure_host_permission_allowed = AsyncMock() mock_vfolder_repository.count_vfolders_by_group = AsyncMock(return_value=0)