Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/11422.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add bulk add/remove/replace operations for role permissions at the repository, service, and action layers.
56 changes: 54 additions & 2 deletions src/ai/backend/manager/data/permission/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@
ObjectPermissionCreateInput,
ObjectPermissionData,
)
from .permission import ScopedPermissionCreateInput
from .permission import PermissionData, ScopedPermissionCreateInput
from .status import RoleStatus
from .types import EntityType, OperationType, RBACElementRef, RBACElementType, RoleSource
from .types import (
EntityType,
OperationType,
RBACElementRef,
RBACElementType,
RoleSource,
ScopeType,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -253,6 +260,51 @@ class BulkRoleRevocationResultData:
failures: list[BulkRoleRevocationFailure] = field(default_factory=list)


@dataclass(frozen=True)
class BulkRolePermissionAddFailure:
"""Failure information for a single permission entry in bulk add (or replace)."""

role_id: uuid.UUID
scope_type: ScopeType
scope_id: str
entity_type: EntityType
operation: OperationType
message: str


@dataclass(frozen=True)
class BulkRolePermissionRemoveFailure:
"""Failure information for a single permission ID in bulk remove."""

permission_id: uuid.UUID
message: str


@dataclass(frozen=True)
class BulkRolePermissionAddResultData:
"""Result of bulk inserting role-permission rows."""

successes: list[PermissionData] = field(default_factory=list)
failures: list[BulkRolePermissionAddFailure] = field(default_factory=list)


@dataclass(frozen=True)
class BulkRolePermissionRemoveResultData:
"""Result of bulk deleting role-permission rows."""

successes: list[PermissionData] = field(default_factory=list)
failures: list[BulkRolePermissionRemoveFailure] = field(default_factory=list)


@dataclass(frozen=True)
class BulkRolePermissionReplaceResultData:
"""Result of replacing a role's entire scoped-permission set."""

role_id: uuid.UUID
successes: list[PermissionData] = field(default_factory=list)
failures: list[BulkRolePermissionAddFailure] = field(default_factory=list)


@dataclass(frozen=True)
class RoleListResult(SearchResult[RoleData]):
"""Result of role search with pagination info."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@
execute_bulk_creator_partial,
execute_creator,
)
from ai.backend.manager.repositories.base.purger import Purger, execute_purger
from ai.backend.manager.repositories.base.purger import (
BulkPurgerResultWithFailures,
Purger,
execute_bulk_purger_partial,
execute_purger,
)
from ai.backend.manager.repositories.base.querier import BatchQuerier, execute_batch_querier
from ai.backend.manager.repositories.base.rbac.entity_creator import (
RBACEntityCreator,
Expand Down Expand Up @@ -530,6 +535,46 @@ async def update_role_permissions(
await db_session.refresh(role_row)
return role_row

async def bulk_add_role_permissions(
self,
creator: BulkCreator[PermissionRow],
) -> BulkCreatorResultWithFailures[PermissionRow]:
"""Bulk-insert permission rows; per-row failures are reported separately."""
async with self._db.begin_session_read_committed() as db_session:
return await execute_bulk_creator_partial(db_session, creator)

async def bulk_remove_role_permissions(
self,
purgers: list[Purger[PermissionRow]],
) -> BulkPurgerResultWithFailures[PermissionRow]:
"""Bulk-delete permission rows by primary key; per-row failures are reported separately."""
async with self._db.begin_session_read_committed() as db_session:
return await execute_bulk_purger_partial(db_session, purgers)

async def replace_role_permissions(
self,
role_id: uuid.UUID,
creator: BulkCreator[PermissionRow],
) -> BulkCreatorResultWithFailures[PermissionRow]:
"""
Replace the role's entire scoped-permission set in a single transaction:
delete all existing rows for ``role_id``, then bulk-insert the rows
defined by ``creator.specs``. Passing a creator with no specs clears
the role's permissions.

- The role's existence is verified first; raises ``ObjectNotFound``
if the role does not exist.
- Each permission row in ``creator.specs`` is assumed to carry the
same ``role_id`` as the one passed to this method; the caller is
responsible for keeping them aligned.
"""
async with self._db.begin_session_read_committed() as db_session:
await self._get_role(db_session, role_id)
await db_session.execute(
sa.delete(PermissionRow).where(PermissionRow.role_id == role_id)
)
return await execute_bulk_creator_partial(db_session, creator)
Comment thread
fregataa marked this conversation as resolved.

async def get_role(self, role_id: uuid.UUID) -> RoleRow | None:
async with self._db.begin_readonly_session_read_committed() as db_session:
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
BulkPermissionCheckInput,
BulkRoleAssignmentFailure,
BulkRoleAssignmentResultData,
BulkRolePermissionAddFailure,
BulkRolePermissionAddResultData,
BulkRolePermissionRemoveFailure,
BulkRolePermissionRemoveResultData,
BulkRolePermissionReplaceResultData,
BulkRoleRevocationResultData,
BulkUserRoleRevocationInput,
EffectivePermissionsInput,
Expand All @@ -42,18 +47,26 @@
UserRoleRevocationInput,
)
from ai.backend.manager.data.permission.types import (
EntityType,
ScopeListResult,
ScopeType,
)
from ai.backend.manager.data.role_invitation.types import RoleInvitationData
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.repositories.base.creator import BulkCreator, Creator
from ai.backend.manager.repositories.base.creator import (
BulkCreator,
Creator,
)
from ai.backend.manager.repositories.base.purger import Purger
from ai.backend.manager.repositories.base.querier import BatchQuerier
from ai.backend.manager.repositories.base.updater import Updater
from ai.backend.manager.repositories.permission_controller.creators import UserRoleCreatorSpec
from ai.backend.manager.repositories.permission_controller.creators import (
PermissionCreatorSpec,
UserRoleCreatorSpec,
)
from ai.backend.manager.repositories.permission_controller.types import (
PermissionSearchScope,
ScopedRoleSearchScope,
Expand Down Expand Up @@ -160,6 +173,70 @@ async def update_role_permissions(
result = await self._db_source.update_role_permissions(input_data=input_data)
return result.to_detail_data_without_users()

@permission_controller_repository_resilience.apply()
async def bulk_add_role_permissions(
self,
creator: BulkCreator[PermissionRow],
) -> BulkRolePermissionAddResultData:
result = await self._db_source.bulk_add_role_permissions(creator)
failures = [
BulkRolePermissionAddFailure(
role_id=(spec := cast(PermissionCreatorSpec, error.spec)).role_id,
scope_type=ScopeType(spec.scope_type.value),
scope_id=spec.scope_id,
entity_type=EntityType(spec.entity_type.value),
operation=spec.operation,
message=str(error.exception),
)
for error in result.errors
]
return BulkRolePermissionAddResultData(
successes=[row.to_data() for row in result.successes],
failures=failures,
)

@permission_controller_repository_resilience.apply()
async def bulk_remove_role_permissions(
self,
purgers: list[Purger[PermissionRow]],
) -> BulkRolePermissionRemoveResultData:
result = await self._db_source.bulk_remove_role_permissions(purgers)
failures = [
BulkRolePermissionRemoveFailure(
permission_id=cast(uuid.UUID, error.purger.pk_value),
message=str(error.exception),
)
for error in result.errors
]
return BulkRolePermissionRemoveResultData(
successes=[row.to_data() for row in result.successes],
failures=failures,
)

@permission_controller_repository_resilience.apply()
async def replace_role_permissions(
self,
role_id: uuid.UUID,
creator: BulkCreator[PermissionRow],
) -> BulkRolePermissionReplaceResultData:
result = await self._db_source.replace_role_permissions(role_id=role_id, creator=creator)
failures = [
BulkRolePermissionAddFailure(
role_id=(spec := cast(PermissionCreatorSpec, error.spec)).role_id,
scope_type=ScopeType(spec.scope_type.value),
scope_id=spec.scope_id,
entity_type=EntityType(spec.entity_type.value),
operation=spec.operation,
message=str(error.exception),
)
for error in result.errors
]
return BulkRolePermissionReplaceResultData(
role_id=role_id,
successes=[row.to_data() for row in result.successes],
failures=failures,
)

@permission_controller_repository_resilience.apply()
async def delete_role(self, updater: Updater[RoleRow]) -> RoleData:
result = await self._db_source.delete_role(updater)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from .assign_role import AssignRoleAction, AssignRoleActionResult
from .bulk_add_role_permissions import (
BulkAddRolePermissionsAction,
BulkAddRolePermissionsActionResult,
)
from .bulk_assign_role import BulkAssignRoleAction, BulkAssignRoleActionResult
from .bulk_remove_role_permissions import (
BulkRemoveRolePermissionsAction,
BulkRemoveRolePermissionsActionResult,
)
from .bulk_revoke_role import BulkRevokeRoleAction, BulkRevokeRoleActionResult
from .check_permission import CheckPermissionAction, CheckPermissionActionResult
from .create_role import CreateRoleAction, CreateRoleActionResult
from .delete_role import DeleteRoleAction, DeleteRoleActionResult
from .get_permission_matrix import GetPermissionMatrixAction, GetPermissionMatrixActionResult
from .get_role_detail import GetRoleDetailAction, GetRoleDetailActionResult
from .purge_role import PurgeRoleAction, PurgeRoleActionResult
from .replace_role_permissions import (
ReplaceRolePermissionsAction,
ReplaceRolePermissionsActionResult,
)
from .resolve_effective_permissions import (
ResolveEffectivePermissionsAction,
ResolveEffectivePermissionsActionResult,
Expand Down Expand Up @@ -35,8 +47,12 @@
__all__ = [
"AssignRoleAction",
"AssignRoleActionResult",
"BulkAddRolePermissionsAction",
"BulkAddRolePermissionsActionResult",
"BulkAssignRoleAction",
"BulkAssignRoleActionResult",
"BulkRemoveRolePermissionsAction",
"BulkRemoveRolePermissionsActionResult",
"BulkRevokeRoleAction",
"BulkRevokeRoleActionResult",
"CheckPermissionAction",
Expand All @@ -51,6 +67,8 @@
"GetRoleDetailActionResult",
"PurgeRoleAction",
"PurgeRoleActionResult",
"ReplaceRolePermissionsAction",
"ReplaceRolePermissionsActionResult",
"ResolveEffectivePermissionsAction",
"ResolveEffectivePermissionsActionResult",
"RevokeRoleAction",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import override

from ai.backend.common.data.permission.types import EntityType
from ai.backend.manager.actions.action import BaseAction, BaseActionResult
from ai.backend.manager.actions.types import ActionOperationType
from ai.backend.manager.data.permission.role import BulkRolePermissionAddResultData
from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow
from ai.backend.manager.repositories.base.creator import BulkCreator
from ai.backend.manager.repositories.permission_controller.creators import (
PermissionCreatorSpec,
)


@dataclass
class BulkAddRolePermissionsAction(BaseAction):
creator: BulkCreator[PermissionRow]

@override
def entity_id(self) -> str | None:
for spec in self.creator.specs:
if isinstance(spec, PermissionCreatorSpec):
return str(spec.role_id)
return None

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

@override
@classmethod
def operation_type(cls) -> ActionOperationType:
return ActionOperationType.UPDATE
Comment thread
fregataa marked this conversation as resolved.


@dataclass
class BulkAddRolePermissionsActionResult(BaseActionResult):
data: BulkRolePermissionAddResultData

@override
def entity_id(self) -> str | None:
for row in self.data.successes:
return str(row.role_id)
for failure in self.data.failures:
return str(failure.role_id)
return None
Comment thread
fregataa marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import override

from ai.backend.common.data.permission.types import EntityType
from ai.backend.manager.actions.action import BaseAction, BaseActionResult
from ai.backend.manager.actions.types import ActionOperationType
from ai.backend.manager.data.permission.role import BulkRolePermissionRemoveResultData
from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow
from ai.backend.manager.repositories.base.purger import Purger


@dataclass
class BulkRemoveRolePermissionsAction(BaseAction):
purgers: list[Purger[PermissionRow]]

@override
def entity_id(self) -> str | None:
return None

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

@override
@classmethod
def operation_type(cls) -> ActionOperationType:
return ActionOperationType.UPDATE
Comment thread
fregataa marked this conversation as resolved.


@dataclass
class BulkRemoveRolePermissionsActionResult(BaseActionResult):
data: BulkRolePermissionRemoveResultData

@override
def entity_id(self) -> str | None:
for row in self.data.successes:
return str(row.role_id)
return None
Loading
Loading