1313
1414import uuid
1515from collections .abc import AsyncIterator
16+ from dataclasses import dataclass
1617from datetime import UTC , datetime
1718from typing import override
1819
2627 ScopeType ,
2728)
2829from ai .backend .common .data .user .types import UserData , UserRole
30+ from ai .backend .common .exception import UnreachableError
2931from ai .backend .manager .actions .action .base import BaseActionTriggerMeta
32+ from ai .backend .manager .actions .action .bulk import BaseBulkAction
3033from ai .backend .manager .actions .action .scope import BaseScopeAction
3134from ai .backend .manager .actions .action .single_entity import BaseSingleEntityAction
3235from ai .backend .manager .actions .action .types import FieldData
3336from ai .backend .manager .actions .types import ActionOperationType
37+ from ai .backend .manager .actions .validator .bulk import DeniedEntity
38+ from ai .backend .manager .actions .validators .rbac .bulk import BulkActionRBACValidator
3439from ai .backend .manager .actions .validators .rbac .legacy import (
3540 LegacyScopeActionRBACValidator ,
3641 LegacySingleEntityActionRBACValidator ,
4247from ai .backend .manager .data .permission .types import RBACElementRef
4348from ai .backend .manager .data .user .types import UserStatus
4449from ai .backend .manager .errors .permission import NotEnoughPermission
45- from ai .backend .manager .errors .user import UserNotFound
4650from ai .backend .manager .models .domain import DomainRow
4751from ai .backend .manager .models .keypair import KeyPairRow
4852from ai .backend .manager .models .rbac_models import UserRoleRow
6569
6670_TARGET_DOMAIN = "default"
6771_TARGET_VFOLDER = "vf-1"
72+ _BULK_VFOLDER_GRANTED = "bulk-vf-granted"
73+ _BULK_VFOLDER_DENIED = "bulk-vf-denied"
6874
6975
7076class _ProjectCreateAction (BaseScopeAction ):
@@ -125,6 +131,25 @@ def field_data(self) -> FieldData | None:
125131 return None
126132
127133
134+ @dataclass
135+ class _BulkVfolderUpdateAction (BaseBulkAction [str ]):
136+ """VFOLDER:UPDATE on multiple vfolders — exercises the bulk validator path."""
137+
138+ @override
139+ def typed_entity_ids (self ) -> list [str ]:
140+ return list (self .entity_ids )
141+
142+ @classmethod
143+ @override
144+ def entity_type (cls ) -> EntityType :
145+ return EntityType .VFOLDER
146+
147+ @classmethod
148+ @override
149+ def operation_type (cls ) -> ActionOperationType :
150+ return ActionOperationType .UPDATE
151+
152+
128153def _make_user_data (user_id : uuid .UUID , * , is_superadmin : bool ) -> UserData :
129154 return UserData (
130155 user_id = user_id ,
@@ -298,6 +323,36 @@ async def regular_user_with_vfolder_update(
298323 return _make_user_data (user_id , is_superadmin = False )
299324
300325
326+ @pytest .fixture
327+ def bulk_vfolder_action () -> _BulkVfolderUpdateAction :
328+ return _BulkVfolderUpdateAction (
329+ entity_ids = [_BULK_VFOLDER_GRANTED , _BULK_VFOLDER_DENIED ],
330+ )
331+
332+
333+ @pytest .fixture
334+ async def regular_user_with_partial_bulk_vfolder_update (
335+ db_with_rbac_tables : ExtendedAsyncSAEngine ,
336+ ) -> UserData :
337+ """User granted VFOLDER:UPDATE only on ``_BULK_VFOLDER_GRANTED``.
338+
339+ Self-scope permission lets the bulk validator return a partial
340+ success — the granted vfolder is allowed, the other denied.
341+ """
342+ user_id = uuid .uuid4 ()
343+ role_id = uuid .uuid4 ()
344+ await _seed_user_with_role (db_with_rbac_tables , user_id = user_id , role_id = role_id )
345+ await _grant_permission (
346+ db_with_rbac_tables ,
347+ role_id = role_id ,
348+ scope_type = ScopeType .VFOLDER ,
349+ scope_id = _BULK_VFOLDER_GRANTED ,
350+ entity_type = EntityType .VFOLDER ,
351+ operation = OperationType .UPDATE ,
352+ )
353+ return _make_user_data (user_id , is_superadmin = False )
354+
355+
301356class TestScopeActionRBACValidator :
302357 async def test_superadmin_bypasses_check (
303358 self ,
@@ -318,7 +373,7 @@ async def test_missing_user_raises(
318373 trigger_meta : BaseActionTriggerMeta ,
319374 ) -> None :
320375 validator = ScopeActionRBACValidator (repository )
321- with pytest .raises (UserNotFound ):
376+ with pytest .raises (UnreachableError ):
322377 await validator .validate (scope_action , trigger_meta )
323378
324379 async def test_non_superadmin_with_permission_passes (
@@ -364,7 +419,7 @@ async def test_missing_user_raises(
364419 trigger_meta : BaseActionTriggerMeta ,
365420 ) -> None :
366421 validator = SingleEntityActionRBACValidator (repository )
367- with pytest .raises (UserNotFound ):
422+ with pytest .raises (UnreachableError ):
368423 await validator .validate (single_entity_action , trigger_meta )
369424
370425 async def test_non_superadmin_with_permission_passes (
@@ -410,7 +465,7 @@ async def test_missing_user_raises(
410465 trigger_meta : BaseActionTriggerMeta ,
411466 ) -> None :
412467 validator = LegacySingleEntityActionRBACValidator (repository )
413- with pytest .raises (UserNotFound ):
468+ with pytest .raises (UnreachableError ):
414469 await validator .validate (single_entity_action , trigger_meta )
415470
416471 async def test_non_superadmin_with_permission_passes (
@@ -455,7 +510,7 @@ async def test_missing_user_raises(
455510 trigger_meta : BaseActionTriggerMeta ,
456511 ) -> None :
457512 validator = LegacyScopeActionRBACValidator (repository )
458- with pytest .raises (UserNotFound ):
513+ with pytest .raises (UnreachableError ):
459514 await validator .validate (scope_action , trigger_meta )
460515
461516 async def test_non_superadmin_with_permission_passes (
@@ -479,3 +534,79 @@ async def test_non_superadmin_without_permission_does_not_raise(
479534 validator = LegacyScopeActionRBACValidator (repository )
480535 with with_user (regular_user_without_permission ):
481536 await validator .validate (scope_action , trigger_meta )
537+
538+
539+ class TestBulkActionRBACValidator :
540+ async def test_superadmin_bypasses_check (
541+ self ,
542+ repository : PermissionControllerRepository ,
543+ bulk_vfolder_action : _BulkVfolderUpdateAction ,
544+ trigger_meta : BaseActionTriggerMeta ,
545+ superadmin_user : UserData ,
546+ ) -> None :
547+ # No permission rows seeded; bypass must approve every entity_id.
548+ validator = BulkActionRBACValidator (repository )
549+ with with_user (superadmin_user ):
550+ result = await validator .validate (bulk_vfolder_action , trigger_meta )
551+
552+ assert result .allowed_entity_ids == [_BULK_VFOLDER_GRANTED , _BULK_VFOLDER_DENIED ]
553+ assert result .denied_entities == []
554+
555+ async def test_missing_user_raises (
556+ self ,
557+ repository : PermissionControllerRepository ,
558+ bulk_vfolder_action : _BulkVfolderUpdateAction ,
559+ trigger_meta : BaseActionTriggerMeta ,
560+ ) -> None :
561+ validator = BulkActionRBACValidator (repository )
562+ with pytest .raises (UnreachableError ):
563+ await validator .validate (bulk_vfolder_action , trigger_meta )
564+
565+ async def test_partial_permission_splits_allowed_and_denied (
566+ self ,
567+ repository : PermissionControllerRepository ,
568+ bulk_vfolder_action : _BulkVfolderUpdateAction ,
569+ trigger_meta : BaseActionTriggerMeta ,
570+ regular_user_with_partial_bulk_vfolder_update : UserData ,
571+ ) -> None :
572+ validator = BulkActionRBACValidator (repository )
573+ with with_user (regular_user_with_partial_bulk_vfolder_update ):
574+ result = await validator .validate (bulk_vfolder_action , trigger_meta )
575+
576+ assert result .allowed_entity_ids == [_BULK_VFOLDER_GRANTED ]
577+ assert result .denied_entities == [
578+ DeniedEntity (entity_id = _BULK_VFOLDER_DENIED , deny_reason = "permission_denied" ),
579+ ]
580+
581+ async def test_no_permission_denies_every_entity (
582+ self ,
583+ repository : PermissionControllerRepository ,
584+ bulk_vfolder_action : _BulkVfolderUpdateAction ,
585+ trigger_meta : BaseActionTriggerMeta ,
586+ regular_user_without_permission : UserData ,
587+ ) -> None :
588+ validator = BulkActionRBACValidator (repository )
589+ with with_user (regular_user_without_permission ):
590+ result = await validator .validate (bulk_vfolder_action , trigger_meta )
591+
592+ assert result .allowed_entity_ids == []
593+ assert result .denied_entities == [
594+ DeniedEntity (entity_id = _BULK_VFOLDER_GRANTED , deny_reason = "permission_denied" ),
595+ DeniedEntity (entity_id = _BULK_VFOLDER_DENIED , deny_reason = "permission_denied" ),
596+ ]
597+
598+ async def test_empty_entity_ids_returns_empty_result (
599+ self ,
600+ repository : PermissionControllerRepository ,
601+ trigger_meta : BaseActionTriggerMeta ,
602+ regular_user_without_permission : UserData ,
603+ ) -> None :
604+ validator = BulkActionRBACValidator (repository )
605+ with with_user (regular_user_without_permission ):
606+ result = await validator .validate (
607+ _BulkVfolderUpdateAction (entity_ids = []),
608+ trigger_meta ,
609+ )
610+
611+ assert result .allowed_entity_ids == []
612+ assert result .denied_entities == []
0 commit comments