Skip to content

Commit c63a32d

Browse files
fregataaclaude
andauthored
feat(BA-6177): role preset repository, service, and processor layers (#11846)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7913476 commit c63a32d

34 files changed

Lines changed: 1438 additions & 0 deletions

File tree

changes/11846.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/ai/backend/manager/data/role_preset/__init__.py

Whitespace-only changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from datetime import datetime
5+
from typing import TYPE_CHECKING
6+
7+
from ai.backend.common.data.permission.types import (
8+
EntityType,
9+
OperationType,
10+
RBACElementType,
11+
)
12+
from ai.backend.common.identifier.role_permission_preset import RolePermissionPresetID
13+
from ai.backend.common.identifier.role_preset import RolePresetID
14+
from ai.backend.manager.repositories.base.creator import BulkCreatorError
15+
from ai.backend.manager.repositories.base.purger import BulkPurgerError
16+
from ai.backend.manager.repositories.base.updater import BulkUpdaterError
17+
18+
if TYPE_CHECKING:
19+
from ai.backend.manager.models.rbac_models.role_permission_preset.row import (
20+
RolePermissionPresetRow,
21+
)
22+
from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow
23+
24+
25+
@dataclass(frozen=True)
26+
class RolePermissionPresetData:
27+
id: RolePermissionPresetID
28+
role_preset_id: RolePresetID
29+
entity_type: EntityType
30+
operation: OperationType
31+
created_at: datetime
32+
33+
34+
@dataclass(frozen=True)
35+
class RolePresetData:
36+
id: RolePresetID
37+
name: str
38+
scope_type: RBACElementType
39+
auto_assign: bool
40+
deleted: bool
41+
created_at: datetime
42+
updated_at: datetime
43+
44+
45+
@dataclass(frozen=True)
46+
class RolePresetSearchResult:
47+
items: list[RolePresetData]
48+
total_count: int
49+
has_next_page: bool
50+
has_previous_page: bool
51+
52+
53+
@dataclass(frozen=True)
54+
class RolePresetBulkPurgeResult:
55+
successes: list[RolePresetData] = field(default_factory=list)
56+
failures: list[BulkPurgerError[RolePresetRow]] = field(default_factory=list)
57+
58+
59+
@dataclass(frozen=True)
60+
class RolePresetBulkUpdateResult:
61+
successes: list[RolePresetData] = field(default_factory=list)
62+
failures: list[BulkUpdaterError[RolePresetRow]] = field(default_factory=list)
63+
64+
65+
@dataclass(frozen=True)
66+
class RolePermissionPresetBulkAddResult:
67+
successes: list[RolePermissionPresetData] = field(default_factory=list)
68+
failures: list[BulkCreatorError[RolePermissionPresetRow]] = field(default_factory=list)
69+
70+
71+
@dataclass(frozen=True)
72+
class RolePermissionPresetBulkRemoveResult:
73+
successes: list[RolePermissionPresetData] = field(default_factory=list)
74+
failures: list[BulkPurgerError[RolePermissionPresetRow]] = field(default_factory=list)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Role preset domain exceptions."""
2+
3+
from __future__ import annotations
4+
5+
from aiohttp import web
6+
7+
from ai.backend.common.exception import (
8+
BackendAIError,
9+
ErrorCode,
10+
ErrorDetail,
11+
ErrorDomain,
12+
ErrorOperation,
13+
)
14+
15+
from .common import ObjectNotFound
16+
17+
18+
class RolePresetNotFound(ObjectNotFound):
19+
object_name = "role_preset"
20+
21+
def error_code(self) -> ErrorCode:
22+
return ErrorCode(
23+
domain=ErrorDomain.ROLE,
24+
operation=ErrorOperation.READ,
25+
error_detail=ErrorDetail.NOT_FOUND,
26+
)
27+
28+
29+
class RolePermissionPresetConflict(BackendAIError, web.HTTPConflict):
30+
error_type = "https://api.backend.ai/probs/duplicate-role-permission-preset"
31+
error_title = "Duplicate role permission preset entry."
32+
33+
def error_code(self) -> ErrorCode:
34+
return ErrorCode(
35+
domain=ErrorDomain.ROLE,
36+
operation=ErrorOperation.CREATE,
37+
error_detail=ErrorDetail.CONFLICT,
38+
)

src/ai/backend/manager/models/rbac_models/role_permission_preset/row.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
EntityType,
1212
OperationType,
1313
)
14+
from ai.backend.manager.data.role_preset.types import RolePermissionPresetData
1415
from ai.backend.manager.models.base import (
1516
GUID,
1617
Base,
@@ -50,3 +51,12 @@ class RolePermissionPresetRow(Base): # type: ignore[misc]
5051
created_at: Mapped[datetime] = mapped_column(
5152
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
5253
)
54+
55+
def to_data(self) -> RolePermissionPresetData:
56+
return RolePermissionPresetData(
57+
id=self.id,
58+
role_preset_id=self.role_preset_id,
59+
entity_type=self.entity_type,
60+
operation=self.operation,
61+
created_at=self.created_at,
62+
)

src/ai/backend/manager/models/rbac_models/role_preset/row.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from ai.backend.common.identifier.role_preset import RolePresetID
1212
from ai.backend.manager.data.permission.types import ScopeType
13+
from ai.backend.manager.data.role_preset.types import RolePresetData
1314
from ai.backend.manager.models.base import (
1415
GUID,
1516
Base,
@@ -53,3 +54,14 @@ class RolePresetRow(Base): # type: ignore[misc]
5354
onupdate=sa.func.now(),
5455
nullable=False,
5556
)
57+
58+
def to_data(self) -> RolePresetData:
59+
return RolePresetData(
60+
id=self.id,
61+
name=self.name,
62+
scope_type=self.scope_type.to_element(),
63+
auto_assign=self.auto_assign,
64+
deleted=self.deleted,
65+
created_at=self.created_at,
66+
updated_at=self.updated_at,
67+
)

src/ai/backend/manager/repositories/ops/provider.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
BulkCreator,
2828
BulkCreatorResult,
2929
BulkCreatorResultWithFailures,
30+
BulkPurgerResultWithFailures,
31+
BulkUpdaterResult,
3032
Creator,
3133
CreatorResult,
3234
DependentCreatorSpec,
@@ -46,6 +48,8 @@
4648
execute_bulk_creator,
4749
execute_bulk_creator_partial,
4850
execute_bulk_dependent_creator,
51+
execute_bulk_purger_partial,
52+
execute_bulk_updater_partial,
4953
execute_creator,
5054
execute_dependent_creator,
5155
execute_next_value_creator,
@@ -175,6 +179,13 @@ async def batch_update[TRow: Base](self, updater: BatchUpdater[TRow]) -> BatchUp
175179
"""Update all rows matching the updater conditions."""
176180
return await execute_batch_updater(self._sess, updater)
177181

182+
async def bulk_update_partial[TRow: Base](
183+
self,
184+
updaters: list[Updater[TRow]],
185+
) -> BulkUpdaterResult[TRow]:
186+
"""Update multiple rows individually, isolating each via a savepoint for partial success."""
187+
return await execute_bulk_updater_partial(self._sess, updaters)
188+
178189
async def upsert[TRow: Base](
179190
self,
180191
upserter: Upserter[TRow],
@@ -191,6 +202,13 @@ async def batch_purge[TRow: Base](self, purger: BatchPurger[TRow]) -> BatchPurge
191202
"""Delete rows in batches matching the purger subquery."""
192203
return await execute_batch_purger(self._sess, purger)
193204

205+
async def bulk_purge_partial[TRow: Base](
206+
self,
207+
purgers: list[Purger[TRow]],
208+
) -> BulkPurgerResultWithFailures[TRow]:
209+
"""Delete multiple rows individually, isolating each via a savepoint for partial success."""
210+
return await execute_bulk_purger_partial(self._sess, purgers)
211+
194212
@asynccontextmanager
195213
async def savepoint(self) -> AsyncIterator[WriteOps]:
196214
"""Open a nested transaction (savepoint) bound to the same session.

src/ai/backend/manager/repositories/repositories.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from ai.backend.manager.repositories.resource_usage_history.repositories import (
5858
ResourceUsageHistoryRepositories,
5959
)
60+
from ai.backend.manager.repositories.role_preset.repositories import RolePresetRepositories
6061
from ai.backend.manager.repositories.runtime_variant.repositories import RuntimeVariantRepositories
6162
from ai.backend.manager.repositories.runtime_variant_preset.repositories import (
6263
RuntimeVariantPresetRepositories,
@@ -108,6 +109,7 @@ class Repositories:
108109
reservoir_registry: ReservoirRegistryRepositories
109110
resource_preset: ResourcePresetRepositories
110111
resource_slot: ResourceSlotRepositories
112+
role_preset: RolePresetRepositories
111113
runtime_variant: RuntimeVariantRepositories
112114
runtime_variant_preset: RuntimeVariantPresetRepositories
113115
deployment_revision_preset: DeploymentRevisionPresetRepositories
@@ -160,6 +162,7 @@ def create(cls, args: RepositoryArgs) -> Self:
160162
reservoir_registry_repositories = ReservoirRegistryRepositories.create(args)
161163
resource_preset_repositories = ResourcePresetRepositories.create(args)
162164
resource_slot_repositories = ResourceSlotRepositories.create(args)
165+
role_preset_repositories = RolePresetRepositories.create(args)
163166
runtime_variant_repositories = RuntimeVariantRepositories.create(args)
164167
runtime_variant_preset_repositories = RuntimeVariantPresetRepositories.create(args)
165168
deployment_revision_preset_repositories = DeploymentRevisionPresetRepositories.create(args)
@@ -209,6 +212,7 @@ def create(cls, args: RepositoryArgs) -> Self:
209212
reservoir_registry=reservoir_registry_repositories,
210213
resource_preset=resource_preset_repositories,
211214
resource_slot=resource_slot_repositories,
215+
role_preset=role_preset_repositories,
212216
runtime_variant=runtime_variant_repositories,
213217
runtime_variant_preset=runtime_variant_preset_repositories,
214218
deployment_revision_preset=deployment_revision_preset_repositories,

src/ai/backend/manager/repositories/role_preset/__init__.py

Whitespace-only changes.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
from typing import override
6+
7+
from ai.backend.common.data.permission.types import (
8+
EntityType,
9+
OperationType,
10+
ScopeType,
11+
)
12+
from ai.backend.common.identifier.role_preset import RolePresetID
13+
from ai.backend.manager.errors.repository import UniqueConstraintViolationError
14+
from ai.backend.manager.errors.role_preset import RolePermissionPresetConflict
15+
from ai.backend.manager.models.rbac_models.role_permission_preset.row import (
16+
RolePermissionPresetRow,
17+
)
18+
from ai.backend.manager.models.rbac_models.role_preset.row import RolePresetRow
19+
from ai.backend.manager.repositories.base.creator import (
20+
CreatorSpec,
21+
DependentCreatorSpec,
22+
)
23+
from ai.backend.manager.repositories.base.types import IntegrityErrorCheck
24+
25+
26+
@dataclass
27+
class RolePresetCreatorSpec(CreatorSpec[RolePresetRow]):
28+
name: str
29+
scope_type: ScopeType
30+
auto_assign: bool = False
31+
32+
@override
33+
def build_row(self) -> RolePresetRow:
34+
return RolePresetRow(
35+
name=self.name,
36+
scope_type=self.scope_type,
37+
auto_assign=self.auto_assign,
38+
)
39+
40+
41+
@dataclass
42+
class RolePermissionPresetDependentCreatorSpec(
43+
DependentCreatorSpec[RolePresetID, RolePermissionPresetRow]
44+
):
45+
entity_type: EntityType
46+
operation: OperationType
47+
48+
@override
49+
def build_row(self, dependency: RolePresetID) -> RolePermissionPresetRow:
50+
return RolePermissionPresetRow(
51+
role_preset_id=dependency,
52+
entity_type=self.entity_type,
53+
operation=self.operation,
54+
)
55+
56+
@property
57+
@override
58+
def integrity_error_checks(self) -> Sequence[IntegrityErrorCheck]:
59+
return (
60+
IntegrityErrorCheck(
61+
violation_type=UniqueConstraintViolationError,
62+
error=RolePermissionPresetConflict(
63+
f"Duplicate permission entry ({self.entity_type}, {self.operation})."
64+
),
65+
),
66+
)
67+
68+
69+
@dataclass
70+
class RolePermissionPresetCreatorSpec(CreatorSpec[RolePermissionPresetRow]):
71+
role_preset_id: RolePresetID
72+
entity_type: EntityType
73+
operation: OperationType
74+
75+
@override
76+
def build_row(self) -> RolePermissionPresetRow:
77+
return RolePermissionPresetRow(
78+
role_preset_id=self.role_preset_id,
79+
entity_type=self.entity_type,
80+
operation=self.operation,
81+
)
82+
83+
@property
84+
@override
85+
def integrity_error_checks(self) -> Sequence[IntegrityErrorCheck]:
86+
return (
87+
IntegrityErrorCheck(
88+
violation_type=UniqueConstraintViolationError,
89+
error=RolePermissionPresetConflict(
90+
f"Duplicate permission entry ({self.entity_type}, {self.operation})."
91+
),
92+
),
93+
)

0 commit comments

Comments
 (0)