diff --git a/changes/11846.feature.md b/changes/11846.feature.md new file mode 100644 index 00000000000..070d67023fe --- /dev/null +++ b/changes/11846.feature.md @@ -0,0 +1 @@ +Add role preset repository, service, and processor layers covering CRUD, soft-delete with restore, purge, and bulk add/remove of `role_permission_preset` entries. diff --git a/src/ai/backend/manager/data/role_preset/__init__.py b/src/ai/backend/manager/data/role_preset/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/data/role_preset/types.py b/src/ai/backend/manager/data/role_preset/types.py new file mode 100644 index 00000000000..3d7aeb8bdc8 --- /dev/null +++ b/src/ai/backend/manager/data/role_preset/types.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING + +from ai.backend.common.data.permission.types import ( + EntityType, + OperationType, + RBACElementType, +) +from ai.backend.common.identifier.role_permission_preset import RolePermissionPresetID +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.manager.repositories.base.creator import BulkCreatorError +from ai.backend.manager.repositories.base.purger import BulkPurgerError +from ai.backend.manager.repositories.base.updater import BulkUpdaterError + +if TYPE_CHECKING: + from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, + ) + from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow + + +@dataclass(frozen=True) +class RolePermissionPresetData: + id: RolePermissionPresetID + role_preset_id: RolePresetID + entity_type: EntityType + operation: OperationType + created_at: datetime + + +@dataclass(frozen=True) +class RolePresetData: + id: RolePresetID + name: str + scope_type: RBACElementType + auto_assign: bool + deleted: bool + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True) +class RolePresetSearchResult: + items: list[RolePresetData] + total_count: int + has_next_page: bool + has_previous_page: bool + + +@dataclass(frozen=True) +class RolePresetBulkPurgeResult: + successes: list[RolePresetData] = field(default_factory=list) + failures: list[BulkPurgerError[RolePresetRow]] = field(default_factory=list) + + +@dataclass(frozen=True) +class RolePresetBulkUpdateResult: + successes: list[RolePresetData] = field(default_factory=list) + failures: list[BulkUpdaterError[RolePresetRow]] = field(default_factory=list) + + +@dataclass(frozen=True) +class RolePermissionPresetBulkAddResult: + successes: list[RolePermissionPresetData] = field(default_factory=list) + failures: list[BulkCreatorError[RolePermissionPresetRow]] = field(default_factory=list) + + +@dataclass(frozen=True) +class RolePermissionPresetBulkRemoveResult: + successes: list[RolePermissionPresetData] = field(default_factory=list) + failures: list[BulkPurgerError[RolePermissionPresetRow]] = field(default_factory=list) diff --git a/src/ai/backend/manager/errors/role_preset.py b/src/ai/backend/manager/errors/role_preset.py new file mode 100644 index 00000000000..2e70cb48787 --- /dev/null +++ b/src/ai/backend/manager/errors/role_preset.py @@ -0,0 +1,38 @@ +"""Role preset domain exceptions.""" + +from __future__ import annotations + +from aiohttp import web + +from ai.backend.common.exception import ( + BackendAIError, + ErrorCode, + ErrorDetail, + ErrorDomain, + ErrorOperation, +) + +from .common import ObjectNotFound + + +class RolePresetNotFound(ObjectNotFound): + object_name = "role_preset" + + def error_code(self) -> ErrorCode: + return ErrorCode( + domain=ErrorDomain.ROLE, + operation=ErrorOperation.READ, + error_detail=ErrorDetail.NOT_FOUND, + ) + + +class RolePermissionPresetConflict(BackendAIError, web.HTTPConflict): + error_type = "https://api.backend.ai/probs/duplicate-role-permission-preset" + error_title = "Duplicate role permission preset entry." + + def error_code(self) -> ErrorCode: + return ErrorCode( + domain=ErrorDomain.ROLE, + operation=ErrorOperation.CREATE, + error_detail=ErrorDetail.CONFLICT, + ) diff --git a/src/ai/backend/manager/models/rbac_models/role_permission_preset/row.py b/src/ai/backend/manager/models/rbac_models/role_permission_preset/row.py index 7fdf6ede41d..391c9a5f8e0 100644 --- a/src/ai/backend/manager/models/rbac_models/role_permission_preset/row.py +++ b/src/ai/backend/manager/models/rbac_models/role_permission_preset/row.py @@ -11,6 +11,7 @@ EntityType, OperationType, ) +from ai.backend.manager.data.role_preset.types import RolePermissionPresetData from ai.backend.manager.models.base import ( GUID, Base, @@ -50,3 +51,12 @@ class RolePermissionPresetRow(Base): # type: ignore[misc] created_at: Mapped[datetime] = mapped_column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() ) + + def to_data(self) -> RolePermissionPresetData: + return RolePermissionPresetData( + id=self.id, + role_preset_id=self.role_preset_id, + entity_type=self.entity_type, + operation=self.operation, + created_at=self.created_at, + ) diff --git a/src/ai/backend/manager/models/rbac_models/role_preset/row.py b/src/ai/backend/manager/models/rbac_models/role_preset/row.py index 2c4ab8dd9ce..9fd96fa6d2e 100644 --- a/src/ai/backend/manager/models/rbac_models/role_preset/row.py +++ b/src/ai/backend/manager/models/rbac_models/role_preset/row.py @@ -10,6 +10,7 @@ from ai.backend.common.identifier.role_preset import RolePresetID from ai.backend.manager.data.permission.types import ScopeType +from ai.backend.manager.data.role_preset.types import RolePresetData from ai.backend.manager.models.base import ( GUID, Base, @@ -53,3 +54,14 @@ class RolePresetRow(Base): # type: ignore[misc] onupdate=sa.func.now(), nullable=False, ) + + def to_data(self) -> RolePresetData: + return RolePresetData( + id=self.id, + name=self.name, + scope_type=self.scope_type.to_element(), + auto_assign=self.auto_assign, + deleted=self.deleted, + created_at=self.created_at, + updated_at=self.updated_at, + ) diff --git a/src/ai/backend/manager/repositories/ops/provider.py b/src/ai/backend/manager/repositories/ops/provider.py index f7c9fbe781a..21c9e188c04 100644 --- a/src/ai/backend/manager/repositories/ops/provider.py +++ b/src/ai/backend/manager/repositories/ops/provider.py @@ -27,6 +27,8 @@ BulkCreator, BulkCreatorResult, BulkCreatorResultWithFailures, + BulkPurgerResultWithFailures, + BulkUpdaterResult, Creator, CreatorResult, DependentCreatorSpec, @@ -46,6 +48,8 @@ execute_bulk_creator, execute_bulk_creator_partial, execute_bulk_dependent_creator, + execute_bulk_purger_partial, + execute_bulk_updater_partial, execute_creator, execute_dependent_creator, execute_next_value_creator, @@ -175,6 +179,13 @@ async def batch_update[TRow: Base](self, updater: BatchUpdater[TRow]) -> BatchUp """Update all rows matching the updater conditions.""" return await execute_batch_updater(self._sess, updater) + async def bulk_update_partial[TRow: Base]( + self, + updaters: list[Updater[TRow]], + ) -> BulkUpdaterResult[TRow]: + """Update multiple rows individually, isolating each via a savepoint for partial success.""" + return await execute_bulk_updater_partial(self._sess, updaters) + async def upsert[TRow: Base]( self, upserter: Upserter[TRow], @@ -191,6 +202,13 @@ async def batch_purge[TRow: Base](self, purger: BatchPurger[TRow]) -> BatchPurge """Delete rows in batches matching the purger subquery.""" return await execute_batch_purger(self._sess, purger) + async def bulk_purge_partial[TRow: Base]( + self, + purgers: list[Purger[TRow]], + ) -> BulkPurgerResultWithFailures[TRow]: + """Delete multiple rows individually, isolating each via a savepoint for partial success.""" + return await execute_bulk_purger_partial(self._sess, purgers) + @asynccontextmanager async def savepoint(self) -> AsyncIterator[WriteOps]: """Open a nested transaction (savepoint) bound to the same session. diff --git a/src/ai/backend/manager/repositories/repositories.py b/src/ai/backend/manager/repositories/repositories.py index a88ab43debf..bd43919c1c1 100644 --- a/src/ai/backend/manager/repositories/repositories.py +++ b/src/ai/backend/manager/repositories/repositories.py @@ -57,6 +57,7 @@ from ai.backend.manager.repositories.resource_usage_history.repositories import ( ResourceUsageHistoryRepositories, ) +from ai.backend.manager.repositories.role_preset.repositories import RolePresetRepositories from ai.backend.manager.repositories.runtime_variant.repositories import RuntimeVariantRepositories from ai.backend.manager.repositories.runtime_variant_preset.repositories import ( RuntimeVariantPresetRepositories, @@ -108,6 +109,7 @@ class Repositories: reservoir_registry: ReservoirRegistryRepositories resource_preset: ResourcePresetRepositories resource_slot: ResourceSlotRepositories + role_preset: RolePresetRepositories runtime_variant: RuntimeVariantRepositories runtime_variant_preset: RuntimeVariantPresetRepositories deployment_revision_preset: DeploymentRevisionPresetRepositories @@ -160,6 +162,7 @@ def create(cls, args: RepositoryArgs) -> Self: reservoir_registry_repositories = ReservoirRegistryRepositories.create(args) resource_preset_repositories = ResourcePresetRepositories.create(args) resource_slot_repositories = ResourceSlotRepositories.create(args) + role_preset_repositories = RolePresetRepositories.create(args) runtime_variant_repositories = RuntimeVariantRepositories.create(args) runtime_variant_preset_repositories = RuntimeVariantPresetRepositories.create(args) deployment_revision_preset_repositories = DeploymentRevisionPresetRepositories.create(args) @@ -209,6 +212,7 @@ def create(cls, args: RepositoryArgs) -> Self: reservoir_registry=reservoir_registry_repositories, resource_preset=resource_preset_repositories, resource_slot=resource_slot_repositories, + role_preset=role_preset_repositories, runtime_variant=runtime_variant_repositories, runtime_variant_preset=runtime_variant_preset_repositories, deployment_revision_preset=deployment_revision_preset_repositories, diff --git a/src/ai/backend/manager/repositories/role_preset/__init__.py b/src/ai/backend/manager/repositories/role_preset/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/repositories/role_preset/creators.py b/src/ai/backend/manager/repositories/role_preset/creators.py new file mode 100644 index 00000000000..8475b6eb455 --- /dev/null +++ b/src/ai/backend/manager/repositories/role_preset/creators.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import ( + EntityType, + OperationType, + ScopeType, +) +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.manager.errors.repository import UniqueConstraintViolationError +from ai.backend.manager.errors.role_preset import RolePermissionPresetConflict +from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, +) +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base.creator import ( + CreatorSpec, + DependentCreatorSpec, +) +from ai.backend.manager.repositories.base.types import IntegrityErrorCheck + + +@dataclass +class RolePresetCreatorSpec(CreatorSpec[RolePresetRow]): + name: str + scope_type: ScopeType + auto_assign: bool = False + + @override + def build_row(self) -> RolePresetRow: + return RolePresetRow( + name=self.name, + scope_type=self.scope_type, + auto_assign=self.auto_assign, + ) + + +@dataclass +class RolePermissionPresetDependentCreatorSpec( + DependentCreatorSpec[RolePresetID, RolePermissionPresetRow] +): + entity_type: EntityType + operation: OperationType + + @override + def build_row(self, dependency: RolePresetID) -> RolePermissionPresetRow: + return RolePermissionPresetRow( + role_preset_id=dependency, + entity_type=self.entity_type, + operation=self.operation, + ) + + @property + @override + def integrity_error_checks(self) -> Sequence[IntegrityErrorCheck]: + return ( + IntegrityErrorCheck( + violation_type=UniqueConstraintViolationError, + error=RolePermissionPresetConflict( + f"Duplicate permission entry ({self.entity_type}, {self.operation})." + ), + ), + ) + + +@dataclass +class RolePermissionPresetCreatorSpec(CreatorSpec[RolePermissionPresetRow]): + role_preset_id: RolePresetID + entity_type: EntityType + operation: OperationType + + @override + def build_row(self) -> RolePermissionPresetRow: + return RolePermissionPresetRow( + role_preset_id=self.role_preset_id, + entity_type=self.entity_type, + operation=self.operation, + ) + + @property + @override + def integrity_error_checks(self) -> Sequence[IntegrityErrorCheck]: + return ( + IntegrityErrorCheck( + violation_type=UniqueConstraintViolationError, + error=RolePermissionPresetConflict( + f"Duplicate permission entry ({self.entity_type}, {self.operation})." + ), + ), + ) diff --git a/src/ai/backend/manager/repositories/role_preset/db_source/__init__.py b/src/ai/backend/manager/repositories/role_preset/db_source/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/repositories/role_preset/db_source/db_source.py b/src/ai/backend/manager/repositories/role_preset/db_source/db_source.py new file mode 100644 index 00000000000..447e8cc67f4 --- /dev/null +++ b/src/ai/backend/manager/repositories/role_preset/db_source/db_source.py @@ -0,0 +1,139 @@ +"""Database source for role preset repository operations. + +Each public method only executes the spec/wrapper handed in by the caller — +no Creator/Updater/Purger objects are constructed here. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +import sqlalchemy as sa + +from ai.backend.common.identifier.role_permission_preset import RolePermissionPresetID +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.logging.utils import BraceStyleAdapter +from ai.backend.manager.data.role_preset.types import ( + RolePermissionPresetBulkAddResult, + RolePermissionPresetBulkRemoveResult, + RolePresetBulkPurgeResult, + RolePresetBulkUpdateResult, + RolePresetData, + RolePresetSearchResult, +) +from ai.backend.manager.errors.role_preset import RolePresetNotFound +from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, +) +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base import ( + BatchQuerier, + BulkCreator, + Creator, + Purger, + Querier, +) +from ai.backend.manager.repositories.base.updater import Updater +from ai.backend.manager.repositories.ops import DBOpsProvider +from ai.backend.manager.repositories.role_preset.creators import ( + RolePermissionPresetDependentCreatorSpec, + RolePresetCreatorSpec, +) + +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class RolePresetDBSource: + _ops: DBOpsProvider + + def __init__(self, ops_provider: DBOpsProvider) -> None: + self._ops = ops_provider + + async def create( + self, + creator_spec: RolePresetCreatorSpec, + permission_creator_specs: Sequence[RolePermissionPresetDependentCreatorSpec], + ) -> RolePresetData: + async with self._ops.write_ops() as w: + created = await w.create(Creator(spec=creator_spec)) + preset_row = created.row + if permission_creator_specs: + await w.bulk_create_dependent(permission_creator_specs, preset_row.id) + return preset_row.to_data() + + async def get(self, preset_id: RolePresetID) -> RolePresetData: + async with self._ops.read_ops() as r: + result = await r.query(Querier(row_class=RolePresetRow, pk_value=preset_id)) + if result is None: + raise RolePresetNotFound(f"Role preset with ID {preset_id} not found.") + return result.row.to_data() + + async def search( + self, + querier: BatchQuerier, + ) -> RolePresetSearchResult: + async with self._ops.read_ops() as r: + result = await r.batch_query_in_global(sa.select(RolePresetRow), querier) + items = [row.RolePresetRow.to_data() for row in result.rows] + return RolePresetSearchResult( + items=items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + async def update( + self, + updater: Updater[RolePresetRow], + ) -> RolePresetData: + async with self._ops.write_ops() as w: + result = await w.update(updater) + if result is None: + raise RolePresetNotFound(f"Role preset with ID {updater.pk_value} not found.") + return result.row.to_data() + + async def bulk_update( + self, + updaters: list[Updater[RolePresetRow]], + ) -> RolePresetBulkUpdateResult: + async with self._ops.write_ops() as w: + result = await w.bulk_update_partial(updaters) + successes = [row.to_data() for row in result.successes] + return RolePresetBulkUpdateResult(successes=successes, failures=result.errors) + + async def purge(self, preset_id: RolePresetID) -> bool: + async with self._ops.write_ops() as w: + result = await w.purge(Purger(row_class=RolePresetRow, pk_value=preset_id)) + return result is not None + + async def bulk_purge( + self, + ids: Sequence[RolePresetID], + ) -> RolePresetBulkPurgeResult: + purgers = [Purger(row_class=RolePresetRow, pk_value=preset_id) for preset_id in ids] + async with self._ops.write_ops() as w: + result = await w.bulk_purge_partial(purgers) + successes = [row.to_data() for row in result.successes] + return RolePresetBulkPurgeResult(successes=successes, failures=result.errors) + + async def bulk_add_permissions( + self, + bulk_creator: BulkCreator[RolePermissionPresetRow], + ) -> RolePermissionPresetBulkAddResult: + if not bulk_creator.specs: + return RolePermissionPresetBulkAddResult() + async with self._ops.write_ops() as w: + result = await w.bulk_create_partial(bulk_creator) + successes = [row.to_data() for row in result.successes] + return RolePermissionPresetBulkAddResult(successes=successes, failures=result.errors) + + async def bulk_remove_permissions( + self, + ids: Sequence[RolePermissionPresetID], + ) -> RolePermissionPresetBulkRemoveResult: + purgers = [Purger(row_class=RolePermissionPresetRow, pk_value=perm_id) for perm_id in ids] + async with self._ops.write_ops() as w: + result = await w.bulk_purge_partial(purgers) + successes = [row.to_data() for row in result.successes] + return RolePermissionPresetBulkRemoveResult(successes=successes, failures=result.errors) diff --git a/src/ai/backend/manager/repositories/role_preset/repositories.py b/src/ai/backend/manager/repositories/role_preset/repositories.py new file mode 100644 index 00000000000..2ece97d5f64 --- /dev/null +++ b/src/ai/backend/manager/repositories/role_preset/repositories.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Self + +from ai.backend.manager.repositories.role_preset.repository import RolePresetRepository +from ai.backend.manager.repositories.types import RepositoryArgs + + +@dataclass +class RolePresetRepositories: + repository: RolePresetRepository + + @classmethod + def create(cls, args: RepositoryArgs) -> Self: + return cls( + repository=RolePresetRepository(args.ops_provider), + ) diff --git a/src/ai/backend/manager/repositories/role_preset/repository.py b/src/ai/backend/manager/repositories/role_preset/repository.py new file mode 100644 index 00000000000..18e07236d11 --- /dev/null +++ b/src/ai/backend/manager/repositories/role_preset/repository.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence + +from ai.backend.common.identifier.role_permission_preset import RolePermissionPresetID +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.data.role_preset.types import ( + RolePermissionPresetBulkAddResult, + RolePermissionPresetBulkRemoveResult, + RolePresetBulkPurgeResult, + RolePresetBulkUpdateResult, + RolePresetData, + RolePresetSearchResult, +) +from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, +) +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base import ( + BatchQuerier, + BulkCreator, +) +from ai.backend.manager.repositories.base.updater import Updater +from ai.backend.manager.repositories.ops import DBOpsProvider +from ai.backend.manager.repositories.role_preset.creators import ( + RolePermissionPresetDependentCreatorSpec, + RolePresetCreatorSpec, +) +from ai.backend.manager.repositories.role_preset.db_source.db_source import ( + RolePresetDBSource, +) + +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class RolePresetRepository: + _db_source: RolePresetDBSource + + def __init__(self, ops_provider: DBOpsProvider) -> None: + self._db_source = RolePresetDBSource(ops_provider) + + async def create( + self, + creator_spec: RolePresetCreatorSpec, + permission_creator_specs: Sequence[RolePermissionPresetDependentCreatorSpec], + ) -> RolePresetData: + return await self._db_source.create(creator_spec, permission_creator_specs) + + async def role_preset(self, preset_id: RolePresetID) -> RolePresetData: + return await self._db_source.get(preset_id) + + async def search( + self, + querier: BatchQuerier, + ) -> RolePresetSearchResult: + return await self._db_source.search(querier) + + async def update(self, updater: Updater[RolePresetRow]) -> RolePresetData: + return await self._db_source.update(updater) + + async def bulk_update( + self, + updaters: list[Updater[RolePresetRow]], + ) -> RolePresetBulkUpdateResult: + return await self._db_source.bulk_update(updaters) + + async def purge(self, preset_id: RolePresetID) -> bool: + return await self._db_source.purge(preset_id) + + async def bulk_purge( + self, + ids: Sequence[RolePresetID], + ) -> RolePresetBulkPurgeResult: + return await self._db_source.bulk_purge(ids) + + async def bulk_add_permissions( + self, + bulk_creator: BulkCreator[RolePermissionPresetRow], + ) -> RolePermissionPresetBulkAddResult: + return await self._db_source.bulk_add_permissions(bulk_creator) + + async def bulk_remove_permissions( + self, + ids: Sequence[RolePermissionPresetID], + ) -> RolePermissionPresetBulkRemoveResult: + return await self._db_source.bulk_remove_permissions(ids) diff --git a/src/ai/backend/manager/repositories/role_preset/updaters.py b/src/ai/backend/manager/repositories/role_preset/updaters.py new file mode 100644 index 00000000000..2cdac3ecab5 --- /dev/null +++ b/src/ai/backend/manager/repositories/role_preset/updaters.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, override + +from ai.backend.common.data.permission.types import ScopeType +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base.updater import UpdaterSpec +from ai.backend.manager.types import OptionalState + + +@dataclass +class RolePresetUpdaterSpec(UpdaterSpec[RolePresetRow]): + """Single-row updater for role preset rows. + + The ``deleted`` column is intentionally not exposed — soft-delete state + is managed only by the dedicated delete/restore operations via + :class:`RolePresetDeletedFlagUpdaterSpec`. + """ + + name: OptionalState[str] = field(default_factory=OptionalState[str].nop) + scope_type: OptionalState[ScopeType] = field(default_factory=OptionalState[ScopeType].nop) + auto_assign: OptionalState[bool] = field(default_factory=OptionalState[bool].nop) + + @property + @override + def row_class(self) -> type[RolePresetRow]: + return RolePresetRow + + @override + def build_values(self) -> dict[str, Any]: + to_update: dict[str, Any] = {} + self.name.update_dict(to_update, "name") + self.scope_type.update_dict(to_update, "scope_type") + self.auto_assign.update_dict(to_update, "auto_assign") + return to_update + + +@dataclass +class RolePresetDeletedFlagUpdaterSpec(UpdaterSpec[RolePresetRow]): + """Per-row update of the ``deleted`` flag — used by bulk delete/restore.""" + + deleted: bool + + @property + @override + def row_class(self) -> type[RolePresetRow]: + return RolePresetRow + + @override + def build_values(self) -> dict[str, Any]: + return {"deleted": self.deleted} diff --git a/src/ai/backend/manager/services/factory.py b/src/ai/backend/manager/services/factory.py index d9ba5d7e15e..d437acb2ee5 100644 --- a/src/ai/backend/manager/services/factory.py +++ b/src/ai/backend/manager/services/factory.py @@ -109,6 +109,8 @@ from ai.backend.manager.services.resource_slot.service import ResourceSlotService from ai.backend.manager.services.resource_usage.processors import ResourceUsageProcessors from ai.backend.manager.services.resource_usage.service import ResourceUsageService +from ai.backend.manager.services.role_preset.processors import RolePresetProcessors +from ai.backend.manager.services.role_preset.service import RolePresetService from ai.backend.manager.services.runtime_variant.processors import RuntimeVariantProcessors from ai.backend.manager.services.runtime_variant.service import RuntimeVariantService from ai.backend.manager.services.runtime_variant_preset.processors import ( @@ -276,6 +278,7 @@ def create_services(args: ServiceArgs) -> Services: repositories.resource_preset.repository, ), resource_slot=ResourceSlotService(repositories.resource_slot.repository), + role_preset=RolePresetService(repositories.role_preset.repository), runtime_variant=RuntimeVariantService( repositories.runtime_variant.repository, ), @@ -468,6 +471,7 @@ def create_processors( services.resource_preset, action_monitors, validators ), resource_slot=ResourceSlotProcessors(services.resource_slot, action_monitors, validators), + role_preset=RolePresetProcessors(services.role_preset, action_monitors, validators), runtime_variant=RuntimeVariantProcessors( services.runtime_variant, action_monitors, validators ), diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index fbde691a5a6..415a87d7cab 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -232,6 +232,12 @@ from ai.backend.manager.services.resource_usage.service import ( ResourceUsageService, ) + from ai.backend.manager.services.role_preset.processors import ( + RolePresetProcessors, + ) + from ai.backend.manager.services.role_preset.service import ( + RolePresetService, + ) from ai.backend.manager.services.runtime_variant.processors import ( RuntimeVariantProcessors, ) @@ -386,6 +392,7 @@ class Services: prometheus_query_preset_category: PrometheusQueryPresetCategoryService resource_preset: ResourcePresetService resource_slot: ResourceSlotService + role_preset: RolePresetService runtime_variant: RuntimeVariantService runtime_variant_preset: RuntimeVariantPresetService deployment_revision_preset: DeploymentRevisionPresetService @@ -451,6 +458,7 @@ class Processors(AbstractProcessorPackage): prometheus_query_preset_category: PrometheusQueryPresetCategoryProcessors resource_preset: ResourcePresetProcessors resource_slot: ResourceSlotProcessors + role_preset: RolePresetProcessors runtime_variant: RuntimeVariantProcessors runtime_variant_preset: RuntimeVariantPresetProcessors deployment_revision_preset: DeploymentRevisionPresetProcessors @@ -509,6 +517,7 @@ def supported_actions(self) -> list[ActionSpec]: *self.prometheus_query_preset_category.supported_actions(), *self.resource_preset.supported_actions(), *self.resource_slot.supported_actions(), + *self.role_preset.supported_actions(), *self.runtime_variant.supported_actions(), *self.runtime_variant_preset.supported_actions(), *self.deployment_revision_preset.supported_actions(), diff --git a/src/ai/backend/manager/services/role_preset/__init__.py b/src/ai/backend/manager/services/role_preset/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/role_preset/actions/__init__.py b/src/ai/backend/manager/services/role_preset/actions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/role_preset/actions/base.py b/src/ai/backend/manager/services/role_preset/actions/base.py new file mode 100644 index 00000000000..c6bee3a1ec6 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/base.py @@ -0,0 +1,66 @@ +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 + + +@dataclass +class RolePresetAction(BaseAction): + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.ROLE + + +@dataclass +class RolePresetBulkAction(RolePresetAction): + """Base for actions that operate on multiple role presets at once. + + Bulk operations target a set of presets rather than a single entity, so + there is no single entity id to report. + """ + + @override + def entity_id(self) -> str | None: + return None + + +@dataclass +class RolePresetScopeAction(RolePresetAction): + """Base for actions scoped to a collection of role presets (e.g. search). + + Scope operations query within an RBAC scope rather than acting on one + entity, so there is no single entity id to report. + """ + + @override + def entity_id(self) -> str | None: + return None + + +@dataclass +class RolePermissionPresetAction(BaseAction): + """Base for actions on role permission presets. + + A role permission preset is a distinct entity from a role preset, so its + actions form their own hierarchy with the ``ROLE_PERMISSION`` entity type. + """ + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.ROLE_PERMISSION + + +@dataclass +class RolePermissionPresetBulkAction(RolePermissionPresetAction): + """Base for actions that operate on multiple role permission presets at once. + + Bulk operations target a set of permission presets rather than a single + entity, so there is no single entity id to report. + """ + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/bulk_add_permissions.py b/src/ai/backend/manager/services/role_preset/actions/bulk_add_permissions.py new file mode 100644 index 00000000000..113b1896dca --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/bulk_add_permissions.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePermissionPresetData +from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, +) +from ai.backend.manager.repositories.base import BulkCreator +from ai.backend.manager.repositories.base.creator import BulkCreatorError +from ai.backend.manager.services.role_preset.actions.base import RolePermissionPresetBulkAction + + +@dataclass +class BulkAddRolePermissionPresetsAction(RolePermissionPresetBulkAction): + bulk_creator: BulkCreator[RolePermissionPresetRow] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + +@dataclass +class BulkAddRolePermissionPresetsActionResult(BaseActionResult): + successes: list[RolePermissionPresetData] = field(default_factory=list) + failures: list[BulkCreatorError[RolePermissionPresetRow]] = field(default_factory=list) + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/bulk_purge.py b/src/ai/backend/manager/services/role_preset/actions/bulk_purge.py new file mode 100644 index 00000000000..98585958a22 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/bulk_purge.py @@ -0,0 +1,28 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from typing import override + +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetBulkPurgeResult +from ai.backend.manager.services.role_preset.actions.base import RolePresetBulkAction + + +@dataclass +class BulkPurgeRolePresetsAction(RolePresetBulkAction): + ids: Sequence[RolePresetID] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.PURGE + + +@dataclass +class BulkPurgeRolePresetsActionResult(BaseActionResult): + result: RolePresetBulkPurgeResult + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/bulk_remove_permissions.py b/src/ai/backend/manager/services/role_preset/actions/bulk_remove_permissions.py new file mode 100644 index 00000000000..fb5987e057e --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/bulk_remove_permissions.py @@ -0,0 +1,33 @@ +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import override + +from ai.backend.common.identifier.role_permission_preset import RolePermissionPresetID +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePermissionPresetData +from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, +) +from ai.backend.manager.repositories.base.purger import BulkPurgerError +from ai.backend.manager.services.role_preset.actions.base import RolePermissionPresetBulkAction + + +@dataclass +class BulkRemoveRolePermissionPresetsAction(RolePermissionPresetBulkAction): + ids: Sequence[RolePermissionPresetID] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + +@dataclass +class BulkRemoveRolePermissionPresetsActionResult(BaseActionResult): + successes: list[RolePermissionPresetData] = field(default_factory=list) + failures: list[BulkPurgerError[RolePermissionPresetRow]] = field(default_factory=list) + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/create.py b/src/ai/backend/manager/services/role_preset/actions/create.py new file mode 100644 index 00000000000..02ab14a25a5 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/create.py @@ -0,0 +1,32 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetData +from ai.backend.manager.repositories.role_preset.creators import ( + RolePermissionPresetDependentCreatorSpec, + RolePresetCreatorSpec, +) +from ai.backend.manager.services.role_preset.actions.base import RolePresetScopeAction + + +@dataclass +class CreateRolePresetAction(RolePresetScopeAction): + creator_spec: RolePresetCreatorSpec + permission_creator_specs: Sequence[RolePermissionPresetDependentCreatorSpec] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.CREATE + + +@dataclass +class CreateRolePresetActionResult(BaseActionResult): + preset: RolePresetData + + @override + def entity_id(self) -> str | None: + return str(self.preset.id) diff --git a/src/ai/backend/manager/services/role_preset/actions/delete.py b/src/ai/backend/manager/services/role_preset/actions/delete.py new file mode 100644 index 00000000000..504c6940349 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/delete.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetData +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base.updater import BulkUpdaterError, Updater +from ai.backend.manager.services.role_preset.actions.base import RolePresetBulkAction + + +@dataclass +class BulkDeleteRolePresetsAction(RolePresetBulkAction): + updaters: list[Updater[RolePresetRow]] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.DELETE + + +@dataclass +class BulkDeleteRolePresetsActionResult(BaseActionResult): + successes: list[RolePresetData] = field(default_factory=list) + failures: list[BulkUpdaterError[RolePresetRow]] = field(default_factory=list) + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/get.py b/src/ai/backend/manager/services/role_preset/actions/get.py new file mode 100644 index 00000000000..e533f286659 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/get.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import override + +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetData +from ai.backend.manager.services.role_preset.actions.base import RolePresetAction + + +@dataclass +class GetRolePresetAction(RolePresetAction): + preset_id: RolePresetID + + @override + def entity_id(self) -> str | None: + return str(self.preset_id) + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + +@dataclass +class GetRolePresetActionResult(BaseActionResult): + preset: RolePresetData + + @override + def entity_id(self) -> str | None: + return str(self.preset.id) diff --git a/src/ai/backend/manager/services/role_preset/actions/purge.py b/src/ai/backend/manager/services/role_preset/actions/purge.py new file mode 100644 index 00000000000..d8123da57ad --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/purge.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import override + +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.services.role_preset.actions.base import RolePresetAction + + +@dataclass +class PurgeRolePresetAction(RolePresetAction): + preset_id: RolePresetID + + @override + def entity_id(self) -> str | None: + return str(self.preset_id) + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.PURGE + + +@dataclass +class PurgeRolePresetActionResult(BaseActionResult): + success: bool + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/restore.py b/src/ai/backend/manager/services/role_preset/actions/restore.py new file mode 100644 index 00000000000..d4bbdf18f48 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/restore.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetData +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base.updater import BulkUpdaterError, Updater +from ai.backend.manager.services.role_preset.actions.base import RolePresetBulkAction + + +@dataclass +class BulkRestoreRolePresetsAction(RolePresetBulkAction): + updaters: list[Updater[RolePresetRow]] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + +@dataclass +class BulkRestoreRolePresetsActionResult(BaseActionResult): + successes: list[RolePresetData] = field(default_factory=list) + failures: list[BulkUpdaterError[RolePresetRow]] = field(default_factory=list) + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/search.py b/src/ai/backend/manager/services/role_preset/actions/search.py new file mode 100644 index 00000000000..8969a66fef0 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/search.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetData +from ai.backend.manager.repositories.base import BatchQuerier +from ai.backend.manager.services.role_preset.actions.base import RolePresetScopeAction + + +@dataclass +class SearchRolePresetsAction(RolePresetScopeAction): + querier: BatchQuerier + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.SEARCH + + +@dataclass +class SearchRolePresetsActionResult(BaseActionResult): + items: list[RolePresetData] + total_count: int + has_next_page: bool + has_previous_page: bool + + @override + def entity_id(self) -> str | None: + return None diff --git a/src/ai/backend/manager/services/role_preset/actions/update.py b/src/ai/backend/manager/services/role_preset/actions/update.py new file mode 100644 index 00000000000..d755204eb6f --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/actions/update.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.action import BaseActionResult +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.role_preset.types import RolePresetData +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.repositories.base.updater import Updater +from ai.backend.manager.services.role_preset.actions.base import RolePresetAction + + +@dataclass +class UpdateRolePresetAction(RolePresetAction): + updater: Updater[RolePresetRow] + + @override + def entity_id(self) -> str | None: + return str(self.updater.pk_value) + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + +@dataclass +class UpdateRolePresetActionResult(BaseActionResult): + preset: RolePresetData + + @override + def entity_id(self) -> str | None: + return str(self.preset.id) diff --git a/src/ai/backend/manager/services/role_preset/processors.py b/src/ai/backend/manager/services/role_preset/processors.py new file mode 100644 index 00000000000..c449677117b --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/processors.py @@ -0,0 +1,98 @@ +from typing import override + +from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.processor import ActionProcessor +from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.actions.validators import ActionValidators +from ai.backend.manager.services.role_preset.actions.bulk_add_permissions import ( + BulkAddRolePermissionPresetsAction, + BulkAddRolePermissionPresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.bulk_purge import ( + BulkPurgeRolePresetsAction, + BulkPurgeRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.bulk_remove_permissions import ( + BulkRemoveRolePermissionPresetsAction, + BulkRemoveRolePermissionPresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.create import ( + CreateRolePresetAction, + CreateRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.actions.delete import ( + BulkDeleteRolePresetsAction, + BulkDeleteRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.get import ( + GetRolePresetAction, + GetRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.actions.purge import ( + PurgeRolePresetAction, + PurgeRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.actions.restore import ( + BulkRestoreRolePresetsAction, + BulkRestoreRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.search import ( + SearchRolePresetsAction, + SearchRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.update import ( + UpdateRolePresetAction, + UpdateRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.service import RolePresetService + + +class RolePresetProcessors(AbstractProcessorPackage): + create: ActionProcessor[CreateRolePresetAction, CreateRolePresetActionResult] + get: ActionProcessor[GetRolePresetAction, GetRolePresetActionResult] + search: ActionProcessor[SearchRolePresetsAction, SearchRolePresetsActionResult] + update: ActionProcessor[UpdateRolePresetAction, UpdateRolePresetActionResult] + bulk_delete: ActionProcessor[BulkDeleteRolePresetsAction, BulkDeleteRolePresetsActionResult] + bulk_restore: ActionProcessor[BulkRestoreRolePresetsAction, BulkRestoreRolePresetsActionResult] + purge: ActionProcessor[PurgeRolePresetAction, PurgeRolePresetActionResult] + bulk_purge: ActionProcessor[BulkPurgeRolePresetsAction, BulkPurgeRolePresetsActionResult] + bulk_add_permissions: ActionProcessor[ + BulkAddRolePermissionPresetsAction, BulkAddRolePermissionPresetsActionResult + ] + bulk_remove_permissions: ActionProcessor[ + BulkRemoveRolePermissionPresetsAction, BulkRemoveRolePermissionPresetsActionResult + ] + + def __init__( + self, + service: RolePresetService, + action_monitors: list[ActionMonitor], + validators: ActionValidators, + ) -> None: + self.create = ActionProcessor(service.create, action_monitors) + self.get = ActionProcessor(service.get, action_monitors) + self.search = ActionProcessor(service.search, action_monitors) + self.update = ActionProcessor(service.update, action_monitors) + self.bulk_delete = ActionProcessor(service.bulk_delete, action_monitors) + self.bulk_restore = ActionProcessor(service.bulk_restore, action_monitors) + self.purge = ActionProcessor(service.purge, action_monitors) + self.bulk_purge = ActionProcessor(service.bulk_purge, action_monitors) + self.bulk_add_permissions = ActionProcessor(service.bulk_add_permissions, action_monitors) + self.bulk_remove_permissions = ActionProcessor( + service.bulk_remove_permissions, action_monitors + ) + + @override + def supported_actions(self) -> list[ActionSpec]: + return [ + CreateRolePresetAction.spec(), + GetRolePresetAction.spec(), + SearchRolePresetsAction.spec(), + UpdateRolePresetAction.spec(), + BulkDeleteRolePresetsAction.spec(), + BulkRestoreRolePresetsAction.spec(), + PurgeRolePresetAction.spec(), + BulkPurgeRolePresetsAction.spec(), + BulkAddRolePermissionPresetsAction.spec(), + BulkRemoveRolePermissionPresetsAction.spec(), + ] diff --git a/src/ai/backend/manager/services/role_preset/service.py b/src/ai/backend/manager/services/role_preset/service.py new file mode 100644 index 00000000000..1000fce86e0 --- /dev/null +++ b/src/ai/backend/manager/services/role_preset/service.py @@ -0,0 +1,120 @@ +import logging + +from ai.backend.logging.utils import BraceStyleAdapter +from ai.backend.manager.repositories.role_preset.repository import RolePresetRepository +from ai.backend.manager.services.role_preset.actions.bulk_add_permissions import ( + BulkAddRolePermissionPresetsAction, + BulkAddRolePermissionPresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.bulk_purge import ( + BulkPurgeRolePresetsAction, + BulkPurgeRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.bulk_remove_permissions import ( + BulkRemoveRolePermissionPresetsAction, + BulkRemoveRolePermissionPresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.create import ( + CreateRolePresetAction, + CreateRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.actions.delete import ( + BulkDeleteRolePresetsAction, + BulkDeleteRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.get import ( + GetRolePresetAction, + GetRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.actions.purge import ( + PurgeRolePresetAction, + PurgeRolePresetActionResult, +) +from ai.backend.manager.services.role_preset.actions.restore import ( + BulkRestoreRolePresetsAction, + BulkRestoreRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.search import ( + SearchRolePresetsAction, + SearchRolePresetsActionResult, +) +from ai.backend.manager.services.role_preset.actions.update import ( + UpdateRolePresetAction, + UpdateRolePresetActionResult, +) + +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class RolePresetService: + _repository: RolePresetRepository + + def __init__(self, repository: RolePresetRepository) -> None: + self._repository = repository + + async def create(self, action: CreateRolePresetAction) -> CreateRolePresetActionResult: + preset = await self._repository.create(action.creator_spec, action.permission_creator_specs) + return CreateRolePresetActionResult(preset=preset) + + async def get(self, action: GetRolePresetAction) -> GetRolePresetActionResult: + preset = await self._repository.role_preset(action.preset_id) + return GetRolePresetActionResult(preset=preset) + + async def search(self, action: SearchRolePresetsAction) -> SearchRolePresetsActionResult: + result = await self._repository.search(action.querier) + return SearchRolePresetsActionResult( + items=result.items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + async def update(self, action: UpdateRolePresetAction) -> UpdateRolePresetActionResult: + preset = await self._repository.update(action.updater) + return UpdateRolePresetActionResult(preset=preset) + + async def bulk_delete( + self, action: BulkDeleteRolePresetsAction + ) -> BulkDeleteRolePresetsActionResult: + result = await self._repository.bulk_update(action.updaters) + return BulkDeleteRolePresetsActionResult( + successes=result.successes, + failures=result.failures, + ) + + async def bulk_restore( + self, action: BulkRestoreRolePresetsAction + ) -> BulkRestoreRolePresetsActionResult: + result = await self._repository.bulk_update(action.updaters) + return BulkRestoreRolePresetsActionResult( + successes=result.successes, + failures=result.failures, + ) + + async def purge(self, action: PurgeRolePresetAction) -> PurgeRolePresetActionResult: + success = await self._repository.purge(action.preset_id) + return PurgeRolePresetActionResult(success=success) + + async def bulk_purge( + self, action: BulkPurgeRolePresetsAction + ) -> BulkPurgeRolePresetsActionResult: + result = await self._repository.bulk_purge(action.ids) + return BulkPurgeRolePresetsActionResult(result=result) + + async def bulk_add_permissions( + self, action: BulkAddRolePermissionPresetsAction + ) -> BulkAddRolePermissionPresetsActionResult: + result = await self._repository.bulk_add_permissions(action.bulk_creator) + return BulkAddRolePermissionPresetsActionResult( + successes=result.successes, + failures=result.failures, + ) + + async def bulk_remove_permissions( + self, action: BulkRemoveRolePermissionPresetsAction + ) -> BulkRemoveRolePermissionPresetsActionResult: + result = await self._repository.bulk_remove_permissions(action.ids) + return BulkRemoveRolePermissionPresetsActionResult( + successes=result.successes, + failures=result.failures, + ) diff --git a/tests/unit/manager/repositories/role_preset/BUILD b/tests/unit/manager/repositories/role_preset/BUILD new file mode 100644 index 00000000000..75b8f46de9b --- /dev/null +++ b/tests/unit/manager/repositories/role_preset/BUILD @@ -0,0 +1 @@ +python_tests(name="tests") diff --git a/tests/unit/manager/repositories/role_preset/test_repository.py b/tests/unit/manager/repositories/role_preset/test_repository.py new file mode 100644 index 00000000000..0d65bc21981 --- /dev/null +++ b/tests/unit/manager/repositories/role_preset/test_repository.py @@ -0,0 +1,289 @@ +"""Tests for the role preset repository. + +Exercises the observable contracts of RolePresetRepository against a real +database: create round-trips (preset + dependent permission rows), id-based +get, search, single-row update, bulk soft delete / restore (per-id partial +update with silent skip for missing ids), single and bulk hard purge (returned +success rows + skipped non-existent ids), bulk add of permission-preset rows +(per-spec partial create), and bulk remove of permission-preset rows (per-id +partial purge). +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from uuid import uuid4 + +import pytest +import sqlalchemy as sa + +from ai.backend.common.data.permission.types import ( + EntityType, + OperationType, + ScopeType, +) +from ai.backend.common.identifier.role_preset import RolePresetID +from ai.backend.manager.errors.role_preset import RolePresetNotFound +from ai.backend.manager.models.rbac_models.role_permission_preset.row import ( + RolePermissionPresetRow, +) +from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.repositories.base import ( + BatchQuerier, + BulkCreator, + NoPagination, +) +from ai.backend.manager.repositories.base.updater import Updater +from ai.backend.manager.repositories.ops import DBOpsProvider +from ai.backend.manager.repositories.role_preset.creators import ( + RolePermissionPresetCreatorSpec, + RolePermissionPresetDependentCreatorSpec, + RolePresetCreatorSpec, +) +from ai.backend.manager.repositories.role_preset.repository import RolePresetRepository +from ai.backend.manager.repositories.role_preset.updaters import ( + RolePresetDeletedFlagUpdaterSpec, + RolePresetUpdaterSpec, +) +from ai.backend.manager.types import OptionalState +from ai.backend.testutils.db import with_tables + + +@pytest.fixture +async def repository( + database_connection: ExtendedAsyncSAEngine, +) -> AsyncGenerator[RolePresetRepository, None]: + async with with_tables( + database_connection, + [ + RolePresetRow, # parent + RolePermissionPresetRow, # child (FK -> role_presets.id, CASCADE) + ], + ): + yield RolePresetRepository(DBOpsProvider(database_connection)) + + +async def _count_permissions( + engine: ExtendedAsyncSAEngine, + preset_id: RolePresetID, +) -> int: + async with engine.begin_readonly_session() as session: + result = await session.execute( + sa.select(sa.func.count()) + .select_from(RolePermissionPresetRow) + .where(RolePermissionPresetRow.role_preset_id == preset_id) + ) + return result.scalar_one() + + +class TestCreate: + async def test_persists_preset(self, repository: RolePresetRepository) -> None: + created = await repository.create( + RolePresetCreatorSpec(name="p1", scope_type=ScopeType.DOMAIN), [] + ) + fetched = await repository.role_preset(created.id) + assert fetched.id == created.id + assert fetched.name == "p1" + assert fetched.scope_type == ScopeType.DOMAIN.to_element() + assert fetched.deleted is False + + async def test_persists_dependent_permission_rows( + self, + repository: RolePresetRepository, + database_connection: ExtendedAsyncSAEngine, + ) -> None: + created = await repository.create( + RolePresetCreatorSpec(name="p1", scope_type=ScopeType.DOMAIN), + [ + RolePermissionPresetDependentCreatorSpec( + entity_type=EntityType.VFOLDER, operation=OperationType.READ + ), + RolePermissionPresetDependentCreatorSpec( + entity_type=EntityType.VFOLDER, operation=OperationType.UPDATE + ), + ], + ) + assert await _count_permissions(database_connection, created.id) == 2 + + +class TestGet: + async def test_round_trip(self, repository: RolePresetRepository) -> None: + created = await repository.create( + RolePresetCreatorSpec(name="p1", scope_type=ScopeType.DOMAIN), [] + ) + fetched = await repository.role_preset(created.id) + assert fetched.id == created.id + + async def test_missing_raises(self, repository: RolePresetRepository) -> None: + with pytest.raises(RolePresetNotFound): + await repository.role_preset(RolePresetID(uuid4())) + + +class TestSearch: + async def test_returns_all_with_count(self, repository: RolePresetRepository) -> None: + await repository.create(RolePresetCreatorSpec(name="a", scope_type=ScopeType.DOMAIN), []) + await repository.create(RolePresetCreatorSpec(name="b", scope_type=ScopeType.DOMAIN), []) + + result = await repository.search(BatchQuerier(pagination=NoPagination())) + + assert result.total_count == 2 + assert {item.name for item in result.items} == {"a", "b"} + assert result.has_next_page is False + assert result.has_previous_page is False + + +class TestUpdate: + async def test_updates_field(self, repository: RolePresetRepository) -> None: + created = await repository.create( + RolePresetCreatorSpec(name="old", scope_type=ScopeType.DOMAIN), [] + ) + updater = Updater( + spec=RolePresetUpdaterSpec(name=OptionalState.update("renamed")), + pk_value=created.id, + ) + + updated = await repository.update(updater) + + assert updated.name == "renamed" + assert (await repository.role_preset(created.id)).name == "renamed" + + async def test_missing_raises(self, repository: RolePresetRepository) -> None: + updater = Updater( + spec=RolePresetUpdaterSpec(name=OptionalState.update("x")), + pk_value=RolePresetID(uuid4()), + ) + with pytest.raises(RolePresetNotFound): + await repository.update(updater) + + +class TestBulkDeleteRestore: + async def test_soft_delete_then_restore(self, repository: RolePresetRepository) -> None: + a = await repository.create( + RolePresetCreatorSpec(name="a", scope_type=ScopeType.DOMAIN), [] + ) + b = await repository.create( + RolePresetCreatorSpec(name="b", scope_type=ScopeType.DOMAIN), [] + ) + ids = [a.id, b.id] + + delete_updaters = [ + Updater(spec=RolePresetDeletedFlagUpdaterSpec(deleted=True), pk_value=preset_id) + for preset_id in ids + ] + delete_result = await repository.bulk_update(delete_updaters) + assert {row.id for row in delete_result.successes} == {a.id, b.id} + assert delete_result.failures == [] + # Soft delete: the row is still present, only the flag flips. + assert (await repository.role_preset(a.id)).deleted is True + + restore_updaters = [ + Updater(spec=RolePresetDeletedFlagUpdaterSpec(deleted=False), pk_value=preset_id) + for preset_id in ids + ] + restore_result = await repository.bulk_update(restore_updaters) + assert {row.id for row in restore_result.successes} == {a.id, b.id} + assert restore_result.failures == [] + assert (await repository.role_preset(a.id)).deleted is False + + async def test_skips_missing_ids(self, repository: RolePresetRepository) -> None: + existing = await repository.create( + RolePresetCreatorSpec(name="x", scope_type=ScopeType.DOMAIN), [] + ) + + # Non-existent id is silently skipped, matching bulk_purge semantics. + updaters = [ + Updater(spec=RolePresetDeletedFlagUpdaterSpec(deleted=True), pk_value=preset_id) + for preset_id in [existing.id, RolePresetID(uuid4())] + ] + result = await repository.bulk_update(updaters) + + assert [row.id for row in result.successes] == [existing.id] + assert result.failures == [] + + +class TestPurge: + async def test_single_purge_success_then_false(self, repository: RolePresetRepository) -> None: + created = await repository.create( + RolePresetCreatorSpec(name="p1", scope_type=ScopeType.DOMAIN), [] + ) + assert await repository.purge(created.id) is True + # Already gone -> no row matched -> False. + assert await repository.purge(created.id) is False + with pytest.raises(RolePresetNotFound): + await repository.role_preset(created.id) + + async def test_bulk_purge_returns_only_existing(self, repository: RolePresetRepository) -> None: + a = await repository.create( + RolePresetCreatorSpec(name="a", scope_type=ScopeType.DOMAIN), [] + ) + b = await repository.create( + RolePresetCreatorSpec(name="b", scope_type=ScopeType.DOMAIN), [] + ) + + # One id does not exist: it is skipped, not an error. + result = await repository.bulk_purge([a.id, b.id, RolePresetID(uuid4())]) + + assert {preset.id for preset in result.successes} == {a.id, b.id} + assert result.failures == [] + + +class TestPermissions: + async def test_bulk_add_returns_rows_bound_to_preset( + self, repository: RolePresetRepository + ) -> None: + preset = await repository.create( + RolePresetCreatorSpec(name="p1", scope_type=ScopeType.DOMAIN), [] + ) + result = await repository.bulk_add_permissions( + BulkCreator( + specs=[ + RolePermissionPresetCreatorSpec( + role_preset_id=preset.id, + entity_type=EntityType.SESSION, + operation=OperationType.READ, + ), + RolePermissionPresetCreatorSpec( + role_preset_id=preset.id, + entity_type=EntityType.SESSION, + operation=OperationType.UPDATE, + ), + ] + ) + ) + + assert len(result.successes) == 2 + assert result.failures == [] + assert all(perm.role_preset_id == preset.id for perm in result.successes) + + async def test_bulk_remove_returns_rows( + self, + repository: RolePresetRepository, + database_connection: ExtendedAsyncSAEngine, + ) -> None: + preset = await repository.create( + RolePresetCreatorSpec(name="p1", scope_type=ScopeType.DOMAIN), [] + ) + added = await repository.bulk_add_permissions( + BulkCreator( + specs=[ + RolePermissionPresetCreatorSpec( + role_preset_id=preset.id, + entity_type=EntityType.SESSION, + operation=OperationType.READ, + ), + RolePermissionPresetCreatorSpec( + role_preset_id=preset.id, + entity_type=EntityType.SESSION, + operation=OperationType.UPDATE, + ), + ] + ) + ) + permission_ids = [perm.id for perm in added.successes] + + result = await repository.bulk_remove_permissions(permission_ids) + + assert {perm.id for perm in result.successes} == set(permission_ids) + assert result.failures == [] + assert await _count_permissions(database_connection, preset.id) == 0