Skip to content

Commit 29bfd3c

Browse files
fregataaclaude
andauthored
feat(BA-5778): wire BulkActionRBACValidator to bulk permission check (#11240)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f78dae0 commit 29bfd3c

6 files changed

Lines changed: 176 additions & 16 deletions

File tree

changes/11240.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Wire BulkActionRBACValidator to the bulk permission check so bulk actions filter unauthorized entities and surface them via partial-success responses.

src/ai/backend/manager/actions/validators/rbac/bulk.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
from typing import Any, override
22

3+
from ai.backend.common.contexts.user import current_user
4+
from ai.backend.common.exception import UnreachableError
35
from ai.backend.manager.actions.action import BaseActionTriggerMeta
46
from ai.backend.manager.actions.action.bulk import BaseBulkAction
57
from ai.backend.manager.actions.validator.bulk import (
68
BulkActionValidator,
79
BulkValidationResult,
10+
DeniedEntity,
811
)
12+
from ai.backend.manager.data.permission.role import BulkPermissionCheckInput
913
from ai.backend.manager.repositories.permission_controller.repository import (
1014
PermissionControllerRepository,
1115
)
1216

17+
_DENY_REASON = "permission_denied"
18+
1319

1420
class BulkActionRBACValidator(BulkActionValidator):
1521
def __init__(
@@ -27,9 +33,31 @@ def name(cls) -> str:
2733
async def validate(
2834
self, action: BaseBulkAction[Any], meta: BaseActionTriggerMeta
2935
) -> BulkValidationResult:
30-
# TODO: wire this to PermissionControllerRepository.check_bulk_permission_with_scope_chain().
31-
# Until then, every entity is treated as allowed so legacy behavior is preserved.
36+
user = current_user()
37+
if user is None:
38+
raise UnreachableError("User context is not available")
39+
entity_ids = list(action.entity_ids)
40+
if user.is_superadmin:
41+
return BulkValidationResult(
42+
allowed_entity_ids=entity_ids,
43+
denied_entities=[],
44+
)
45+
permission_map = await self._repository.check_bulk_permission_with_scope_chain(
46+
BulkPermissionCheckInput(
47+
user_id=user.user_id,
48+
target_element_type=action.entity_type().to_element(),
49+
target_entity_ids=entity_ids,
50+
operation=action.operation_type().to_permission_operation(),
51+
)
52+
)
53+
allowed_entity_ids: list[str] = []
54+
denied_entities: list[DeniedEntity] = []
55+
for eid in entity_ids:
56+
if permission_map.get(eid, False):
57+
allowed_entity_ids.append(eid)
58+
else:
59+
denied_entities.append(DeniedEntity(entity_id=eid, deny_reason=_DENY_REASON))
3260
return BulkValidationResult(
33-
allowed_entity_ids=list(action.entity_ids),
34-
denied_entities=[],
61+
allowed_entity_ids=allowed_entity_ids,
62+
denied_entities=denied_entities,
3563
)

src/ai/backend/manager/actions/validators/rbac/legacy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import override
1010

1111
from ai.backend.common.contexts.user import current_user
12+
from ai.backend.common.exception import UnreachableError
1213
from ai.backend.common.metrics.safe import SafeCounter
1314
from ai.backend.logging.utils import BraceStyleAdapter
1415
from ai.backend.manager.actions.action import BaseActionTriggerMeta
@@ -17,7 +18,6 @@
1718
from ai.backend.manager.actions.validator.scope import ScopeActionValidator
1819
from ai.backend.manager.actions.validator.single_entity import SingleEntityActionValidator
1920
from ai.backend.manager.data.permission.role import ScopeChainPermissionCheckInput
20-
from ai.backend.manager.errors.user import UserNotFound
2121
from ai.backend.manager.repositories.permission_controller.repository import (
2222
PermissionControllerRepository,
2323
)
@@ -53,7 +53,7 @@ def __init__(
5353
async def validate(self, action: BaseSingleEntityAction, meta: BaseActionTriggerMeta) -> None:
5454
user = current_user()
5555
if user is None:
56-
raise UserNotFound("User not found in context")
56+
raise UnreachableError("User context is not available")
5757
if user.is_superadmin:
5858
return
5959

@@ -94,7 +94,7 @@ def __init__(
9494
async def validate(self, action: BaseScopeAction, meta: BaseActionTriggerMeta) -> None:
9595
user = current_user()
9696
if user is None:
97-
raise UserNotFound("User not found in context")
97+
raise UnreachableError("User context is not available")
9898
if user.is_superadmin:
9999
return
100100

src/ai/backend/manager/actions/validators/rbac/scope.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from typing import override
22

33
from ai.backend.common.contexts.user import current_user
4+
from ai.backend.common.exception import UnreachableError
45
from ai.backend.manager.actions.action import BaseActionTriggerMeta
56
from ai.backend.manager.actions.action.scope import BaseScopeAction
67
from ai.backend.manager.actions.validator.scope import ScopeActionValidator
78
from ai.backend.manager.data.permission.role import ScopeChainPermissionCheckInput
89
from ai.backend.manager.errors.permission import NotEnoughPermission
9-
from ai.backend.manager.errors.user import UserNotFound
1010
from ai.backend.manager.repositories.permission_controller.repository import (
1111
PermissionControllerRepository,
1212
)
@@ -23,7 +23,7 @@ def __init__(
2323
async def validate(self, action: BaseScopeAction, meta: BaseActionTriggerMeta) -> None:
2424
user = current_user()
2525
if user is None:
26-
raise UserNotFound("User not found in context")
26+
raise UnreachableError("User context is not available")
2727
if user.is_superadmin:
2828
return
2929

src/ai/backend/manager/actions/validators/rbac/single_entity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from typing import override
22

33
from ai.backend.common.contexts.user import current_user
4+
from ai.backend.common.exception import UnreachableError
45
from ai.backend.manager.actions.action import BaseActionTriggerMeta
56
from ai.backend.manager.actions.action.single_entity import BaseSingleEntityAction
67
from ai.backend.manager.actions.validator.single_entity import SingleEntityActionValidator
78
from ai.backend.manager.data.permission.role import ScopeChainPermissionCheckInput
89
from ai.backend.manager.errors.permission import NotEnoughPermission
9-
from ai.backend.manager.errors.user import UserNotFound
1010
from ai.backend.manager.repositories.permission_controller.repository import (
1111
PermissionControllerRepository,
1212
)
@@ -23,7 +23,7 @@ def __init__(
2323
async def validate(self, action: BaseSingleEntityAction, meta: BaseActionTriggerMeta) -> None:
2424
user = current_user()
2525
if user is None:
26-
raise UserNotFound("User not found in context")
26+
raise UnreachableError("User context is not available")
2727
if user.is_superadmin:
2828
return
2929

tests/unit/manager/actions/validators/test_rbac_validators.py

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import uuid
1515
from collections.abc import AsyncIterator
16+
from dataclasses import dataclass
1617
from datetime import UTC, datetime
1718
from typing import override
1819

@@ -26,11 +27,15 @@
2627
ScopeType,
2728
)
2829
from ai.backend.common.data.user.types import UserData, UserRole
30+
from ai.backend.common.exception import UnreachableError
2931
from ai.backend.manager.actions.action.base import BaseActionTriggerMeta
32+
from ai.backend.manager.actions.action.bulk import BaseBulkAction
3033
from ai.backend.manager.actions.action.scope import BaseScopeAction
3134
from ai.backend.manager.actions.action.single_entity import BaseSingleEntityAction
3235
from ai.backend.manager.actions.action.types import FieldData
3336
from 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
3439
from ai.backend.manager.actions.validators.rbac.legacy import (
3540
LegacyScopeActionRBACValidator,
3641
LegacySingleEntityActionRBACValidator,
@@ -42,7 +47,6 @@
4247
from ai.backend.manager.data.permission.types import RBACElementRef
4348
from ai.backend.manager.data.user.types import UserStatus
4449
from ai.backend.manager.errors.permission import NotEnoughPermission
45-
from ai.backend.manager.errors.user import UserNotFound
4650
from ai.backend.manager.models.domain import DomainRow
4751
from ai.backend.manager.models.keypair import KeyPairRow
4852
from ai.backend.manager.models.rbac_models import UserRoleRow
@@ -65,6 +69,8 @@
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

7076
class _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+
128153
def _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+
301356
class 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

Comments
 (0)