Skip to content

Commit d4aafd7

Browse files
rapsealkclaude
andcommitted
fix: Scope group vfolders by group_id in the REST folder list
`GET /folders` accepts a `group_id` query parameter and the handler resolves it into a PROJECT scope, but `VFolderService.list` ignored the scope — it called `list_accessible_vfolders` without it and only echoed `_scope_type`/`_scope_id` back in the result. So the listing always returned the full accessible union (every MODEL_STORE project, plus every domain project for admins), making `group_id` a silent no-op. Thread a `group_scope_id` through `VFolderRepository.list_accessible_vfolders` into `query_accessible_vfolders` as `extra_vf_group_conds` (group == scope_id), applied only when the request is project-scoped. User-owned and invited vfolders are unaffected, so a project-scoped request returns the caller's own folders plus that project's group folders — matching the `vfolder_nodes` GraphQL connection. Fixes #12363. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5a8b42 commit d4aafd7

4 files changed

Lines changed: 70 additions & 0 deletions

File tree

changes/12363.fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Honor the `group_id` query parameter of the `GET /folders` REST API so the virtual folder listing can be scoped to a single project, instead of always returning every accessible folder (including model-store and, for admins, all cross-project folders) regardless of the requested scope.

src/ai/backend/manager/repositories/vfolder/repository.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import dataclass
44
from datetime import UTC, datetime
55
from typing import Any, cast
6+
from uuid import UUID
67

78
import sqlalchemy as sa
89
from sqlalchemy import exc as sa_exc
@@ -415,9 +416,13 @@ async def list_accessible_vfolders(
415416
domain_name: str,
416417
allowed_vfolder_types: list[str],
417418
extra_conditions: sa.sql.elements.ColumnElement[bool] | None = None,
419+
group_scope_id: UUID | None = None,
418420
) -> VFolderListResult:
419421
"""
420422
List all VFolders accessible to a user.
423+
424+
When ``group_scope_id`` is given, group-owned vfolders are restricted to
425+
that project; user-owned and invited vfolders are unaffected.
421426
Returns VFolderListResult with access information.
422427
"""
423428
async with self._db.begin_readonly_session() as session:
@@ -429,6 +434,9 @@ async def list_accessible_vfolders(
429434
domain_name=domain_name,
430435
allowed_vfolder_types=allowed_vfolder_types,
431436
extra_vf_conds=extra_conditions,
437+
extra_vf_group_conds=(
438+
(VFolderRow.group == group_scope_id) if group_scope_id is not None else None
439+
),
432440
)
433441

434442
vfolder_access_infos = []

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Any,
99
cast,
1010
)
11+
from uuid import UUID
1112

1213
import aiohttp
1314
import msgpack
@@ -18,6 +19,7 @@
1819
from ai.backend.common.bgtask.bgtask import BackgroundTaskManager
1920
from ai.backend.common.clients.valkey_client.valkey_stat.client import ValkeyStatClient
2021
from ai.backend.common.contexts.user import current_user
22+
from ai.backend.common.data.permission.types import ScopeType
2123
from ai.backend.common.defs import VFOLDER_GROUP_PERMISSION_MODE
2224
from ai.backend.common.etcd import AsyncEtcd
2325
from ai.backend.common.exception import UnreachableError
@@ -579,12 +581,22 @@ async def list(self, action: ListVFolderAction) -> ListVFolderActionResult:
579581
raise ObjectNotFound(object_name="User")
580582
user_role, user_domain_name = user_info
581583

584+
# When scoped to a project (i.e. the request carried a `group_id`),
585+
# restrict group-owned vfolders to that project. Without this, the
586+
# listing returns the full accessible union (every MODEL_STORE project,
587+
# and every domain project for admins) regardless of the scope, which
588+
# made the REST `group_id` query parameter a no-op.
589+
group_scope_id: UUID | None = None
590+
if action.scope_type() == ScopeType.PROJECT:
591+
group_scope_id = UUID(action.scope_id())
592+
582593
# Use repository to get accessible vfolders
583594
vfolder_list_result = await self._vfolder_repository.list_accessible_vfolders(
584595
user_id=action.user_uuid,
585596
user_role=user_role,
586597
domain_name=user_domain_name,
587598
allowed_vfolder_types=list(allowed_vfolder_types),
599+
group_scope_id=group_scope_id,
588600
)
589601

590602
vfolders = [

tests/unit/manager/services/vfolder/test_vfolder_crud_service.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,55 @@ async def test_no_vfolders_returns_empty_list(
587587
assert isinstance(result, ListVFolderActionResult)
588588
assert result.vfolders == []
589589

590+
async def test_project_scope_restricts_group_vfolders(
591+
self,
592+
vfolder_service: VFolderService,
593+
mock_vfolder_repository: MagicMock,
594+
user_uuid: uuid.UUID,
595+
group_uuid: uuid.UUID,
596+
) -> None:
597+
mock_vfolder_repository.get_user_info = AsyncMock(return_value=(UserRole.USER, "default"))
598+
mock_vfolder_repository.list_accessible_vfolders = AsyncMock(
599+
return_value=VFolderListResult(vfolders=[])
600+
)
601+
602+
action = ListVFolderAction(
603+
user_uuid=user_uuid,
604+
_scope_type=ScopeType.PROJECT,
605+
_scope_id=str(group_uuid),
606+
)
607+
608+
await vfolder_service.list(action)
609+
610+
assert (
611+
mock_vfolder_repository.list_accessible_vfolders.call_args.kwargs["group_scope_id"]
612+
== group_uuid
613+
)
614+
615+
async def test_user_scope_does_not_restrict_group_vfolders(
616+
self,
617+
vfolder_service: VFolderService,
618+
mock_vfolder_repository: MagicMock,
619+
user_uuid: uuid.UUID,
620+
) -> None:
621+
mock_vfolder_repository.get_user_info = AsyncMock(return_value=(UserRole.USER, "default"))
622+
mock_vfolder_repository.list_accessible_vfolders = AsyncMock(
623+
return_value=VFolderListResult(vfolders=[])
624+
)
625+
626+
action = ListVFolderAction(
627+
user_uuid=user_uuid,
628+
_scope_type=ScopeType.USER,
629+
_scope_id=str(user_uuid),
630+
)
631+
632+
await vfolder_service.list(action)
633+
634+
assert (
635+
mock_vfolder_repository.list_accessible_vfolders.call_args.kwargs["group_scope_id"]
636+
is None
637+
)
638+
590639
async def test_returns_owned_and_shared_vfolders(
591640
self,
592641
vfolder_service: VFolderService,

0 commit comments

Comments
 (0)