Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/11240.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Wire BulkActionRBACValidator to the bulk permission check so bulk actions filter unauthorized entities and surface them via partial-success responses.
36 changes: 32 additions & 4 deletions src/ai/backend/manager/actions/validators/rbac/bulk.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
from typing import Any, override

from ai.backend.common.contexts.user import current_user
from ai.backend.manager.actions.action import BaseActionTriggerMeta
from ai.backend.manager.actions.action.bulk import BaseBulkAction
from ai.backend.manager.actions.validator.bulk import (
BulkActionValidator,
BulkValidationResult,
DeniedEntity,
)
from ai.backend.manager.data.permission.role import BulkPermissionCheckInput
from ai.backend.manager.errors.user import UserNotFound
from ai.backend.manager.repositories.permission_controller.repository import (
PermissionControllerRepository,
)

_DENY_REASON = "permission_denied"
Comment thread
fregataa marked this conversation as resolved.


class BulkActionRBACValidator(BulkActionValidator):
def __init__(
Expand All @@ -27,9 +33,31 @@ def name(cls) -> str:
async def validate(
self, action: BaseBulkAction[Any], meta: BaseActionTriggerMeta
) -> BulkValidationResult:
# TODO: wire this to PermissionControllerRepository.check_bulk_permission_with_scope_chain().
# Until then, every entity is treated as allowed so legacy behavior is preserved.
user = current_user()
if user is None:
raise UserNotFound("User not found in context")
Comment thread
fregataa marked this conversation as resolved.
Outdated
entity_ids = list(action.entity_ids)
if user.is_superadmin:
return BulkValidationResult(
allowed_entity_ids=entity_ids,
denied_entities=[],
)
permission_map = await self._repository.check_bulk_permission_with_scope_chain(
Comment thread
fregataa marked this conversation as resolved.
BulkPermissionCheckInput(
user_id=user.user_id,
target_element_type=action.entity_type().to_element(),
target_entity_ids=entity_ids,
operation=action.operation_type().to_permission_operation(),
)
)
allowed_entity_ids: list[str] = []
denied_entities: list[DeniedEntity] = []
for eid in entity_ids:
if permission_map.get(eid, False):
allowed_entity_ids.append(eid)
else:
denied_entities.append(DeniedEntity(entity_id=eid, deny_reason=_DENY_REASON))
return BulkValidationResult(
allowed_entity_ids=list(action.entity_ids),
denied_entities=[],
allowed_entity_ids=allowed_entity_ids,
denied_entities=denied_entities,
)
131 changes: 131 additions & 0 deletions tests/unit/manager/actions/validators/test_rbac_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import override

Expand All @@ -27,10 +28,13 @@
)
from ai.backend.common.data.user.types import UserData, UserRole
from ai.backend.manager.actions.action.base import BaseActionTriggerMeta
from ai.backend.manager.actions.action.bulk import BaseBulkAction
from ai.backend.manager.actions.action.scope import BaseScopeAction
from ai.backend.manager.actions.action.single_entity import BaseSingleEntityAction
from ai.backend.manager.actions.action.types import FieldData
from ai.backend.manager.actions.types import ActionOperationType
from ai.backend.manager.actions.validator.bulk import DeniedEntity
from ai.backend.manager.actions.validators.rbac.bulk import BulkActionRBACValidator
from ai.backend.manager.actions.validators.rbac.legacy import (
LegacyScopeActionRBACValidator,
LegacySingleEntityActionRBACValidator,
Expand Down Expand Up @@ -65,6 +69,8 @@

_TARGET_DOMAIN = "default"
_TARGET_VFOLDER = "vf-1"
_BULK_VFOLDER_GRANTED = "bulk-vf-granted"
_BULK_VFOLDER_DENIED = "bulk-vf-denied"


class _ProjectCreateAction(BaseScopeAction):
Expand Down Expand Up @@ -125,6 +131,25 @@ def field_data(self) -> FieldData | None:
return None


@dataclass
class _BulkVfolderUpdateAction(BaseBulkAction[str]):
"""VFOLDER:UPDATE on multiple vfolders — exercises the bulk validator path."""

@override
def typed_entity_ids(self) -> list[str]:
return list(self.entity_ids)

@classmethod
@override
def entity_type(cls) -> EntityType:
return EntityType.VFOLDER

@classmethod
@override
def operation_type(cls) -> ActionOperationType:
return ActionOperationType.UPDATE


def _make_user_data(user_id: uuid.UUID, *, is_superadmin: bool) -> UserData:
return UserData(
user_id=user_id,
Expand Down Expand Up @@ -298,6 +323,36 @@ async def regular_user_with_vfolder_update(
return _make_user_data(user_id, is_superadmin=False)


@pytest.fixture
def bulk_vfolder_action() -> _BulkVfolderUpdateAction:
return _BulkVfolderUpdateAction(
entity_ids=[_BULK_VFOLDER_GRANTED, _BULK_VFOLDER_DENIED],
)


@pytest.fixture
async def regular_user_with_partial_bulk_vfolder_update(
db_with_rbac_tables: ExtendedAsyncSAEngine,
) -> UserData:
"""User granted VFOLDER:UPDATE only on ``_BULK_VFOLDER_GRANTED``.

Self-scope permission lets the bulk validator return a partial
success — the granted vfolder is allowed, the other denied.
"""
user_id = uuid.uuid4()
role_id = uuid.uuid4()
await _seed_user_with_role(db_with_rbac_tables, user_id=user_id, role_id=role_id)
await _grant_permission(
db_with_rbac_tables,
role_id=role_id,
scope_type=ScopeType.VFOLDER,
scope_id=_BULK_VFOLDER_GRANTED,
entity_type=EntityType.VFOLDER,
operation=OperationType.UPDATE,
)
return _make_user_data(user_id, is_superadmin=False)


class TestScopeActionRBACValidator:
async def test_superadmin_bypasses_check(
self,
Expand Down Expand Up @@ -479,3 +534,79 @@ async def test_non_superadmin_without_permission_does_not_raise(
validator = LegacyScopeActionRBACValidator(repository)
with with_user(regular_user_without_permission):
await validator.validate(scope_action, trigger_meta)


class TestBulkActionRBACValidator:
async def test_superadmin_bypasses_check(
self,
repository: PermissionControllerRepository,
bulk_vfolder_action: _BulkVfolderUpdateAction,
trigger_meta: BaseActionTriggerMeta,
superadmin_user: UserData,
) -> None:
# No permission rows seeded; bypass must approve every entity_id.
validator = BulkActionRBACValidator(repository)
with with_user(superadmin_user):
result = await validator.validate(bulk_vfolder_action, trigger_meta)

assert result.allowed_entity_ids == [_BULK_VFOLDER_GRANTED, _BULK_VFOLDER_DENIED]
assert result.denied_entities == []

async def test_missing_user_raises(
self,
repository: PermissionControllerRepository,
bulk_vfolder_action: _BulkVfolderUpdateAction,
trigger_meta: BaseActionTriggerMeta,
) -> None:
validator = BulkActionRBACValidator(repository)
with pytest.raises(UserNotFound):
await validator.validate(bulk_vfolder_action, trigger_meta)

async def test_partial_permission_splits_allowed_and_denied(
self,
repository: PermissionControllerRepository,
bulk_vfolder_action: _BulkVfolderUpdateAction,
trigger_meta: BaseActionTriggerMeta,
regular_user_with_partial_bulk_vfolder_update: UserData,
) -> None:
validator = BulkActionRBACValidator(repository)
with with_user(regular_user_with_partial_bulk_vfolder_update):
result = await validator.validate(bulk_vfolder_action, trigger_meta)

assert result.allowed_entity_ids == [_BULK_VFOLDER_GRANTED]
assert result.denied_entities == [
DeniedEntity(entity_id=_BULK_VFOLDER_DENIED, deny_reason="permission_denied"),
]

async def test_no_permission_denies_every_entity(
self,
repository: PermissionControllerRepository,
bulk_vfolder_action: _BulkVfolderUpdateAction,
trigger_meta: BaseActionTriggerMeta,
regular_user_without_permission: UserData,
) -> None:
validator = BulkActionRBACValidator(repository)
with with_user(regular_user_without_permission):
result = await validator.validate(bulk_vfolder_action, trigger_meta)

assert result.allowed_entity_ids == []
assert result.denied_entities == [
DeniedEntity(entity_id=_BULK_VFOLDER_GRANTED, deny_reason="permission_denied"),
DeniedEntity(entity_id=_BULK_VFOLDER_DENIED, deny_reason="permission_denied"),
]

async def test_empty_entity_ids_returns_empty_result(
self,
repository: PermissionControllerRepository,
trigger_meta: BaseActionTriggerMeta,
regular_user_without_permission: UserData,
) -> None:
validator = BulkActionRBACValidator(repository)
with with_user(regular_user_without_permission):
result = await validator.validate(
_BulkVfolderUpdateAction(entity_ids=[]),
trigger_meta,
)

assert result.allowed_entity_ids == []
assert result.denied_entities == []
Loading