Skip to content

Commit d25b1e7

Browse files
fregataaclaude
andcommitted
feat(BA-6191): add ScopeCreatorSpec, ScopePurgerSpec, and ScopeWriteOps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c63a32d commit d25b1e7

5 files changed

Lines changed: 427 additions & 2 deletions

File tree

changes/11843.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `ScopeCreatorSpec` / `ScopePurgerSpec` composite specs and a dedicated `ScopeWriteOps` class that provisions a new RBAC scope (scope row, preset-derived roles + permissions, role-to-scope and parent-scope associations) or tears one down in a single transaction.

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@
6363
execute_batch_querier,
6464
execute_querier,
6565
)
66+
from .scope_creator import (
67+
ScopeContext,
68+
ScopeCreator,
69+
ScopeCreatorResult,
70+
ScopeCreatorSpec,
71+
)
72+
from .scope_purger import (
73+
ScopePurger,
74+
ScopePurgerResult,
75+
ScopePurgerSpec,
76+
)
6677
from .types import (
6778
CursorConditionFactory,
6879
ExistenceCheck,
@@ -190,6 +201,15 @@
190201
"BatchPurger",
191202
"BatchPurgerResult",
192203
"execute_batch_purger",
204+
# ScopeCreator
205+
"ScopeCreatorSpec",
206+
"ScopeCreator",
207+
"ScopeCreatorResult",
208+
"ScopeContext",
209+
# ScopePurger
210+
"ScopePurgerSpec",
211+
"ScopePurger",
212+
"ScopePurgerResult",
193213
# Utils
194214
"combine_conditions_and",
195215
"combine_conditions_or",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Composite spec for race-free scope provisioning.
2+
3+
A scope-creator spec coordinates the inserts that together provision a new RBAC
4+
scope: the scope row itself (domain, project, ...), any parent-scope mapping
5+
rows, and roles + role-scope associations + permissions instantiated from each
6+
active role preset matching the scope type.
7+
8+
The spec itself owns no table; each returned sub-spec owns exactly one table,
9+
preserving the per-spec single-table rule. Orchestration lives in
10+
:meth:`ScopeWriteOps.create_scope`.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from abc import ABC, abstractmethod
16+
from collections.abc import Sequence
17+
from dataclasses import dataclass
18+
19+
from ai.backend.common.data.permission.types import RBACElementType
20+
from ai.backend.manager.models.base import Base
21+
from ai.backend.manager.models.rbac_models.association_scopes_entities import (
22+
AssociationScopesEntitiesRow,
23+
)
24+
from ai.backend.manager.models.rbac_models.role import RoleRow
25+
26+
from .creator import CreatorSpec
27+
28+
29+
@dataclass(frozen=True)
30+
class ScopeContext:
31+
"""Locator for a scope row.
32+
33+
Carries the ``(scope_type, scope_id)`` pair used by downstream RBAC tables
34+
(``permissions``, ``association_scopes_entities``) to reference the scope.
35+
"""
36+
37+
scope_type: RBACElementType
38+
scope_id: str
39+
40+
41+
class ScopeCreatorSpec[TScopeRow: Base](ABC):
42+
"""Coordinator for ``scope row + parent-scope association rows``.
43+
44+
Subclass per scope type (e.g. ``DomainScopeCreatorSpec``,
45+
``ProjectScopeCreatorSpec``). Each returned sub-spec owns exactly one table.
46+
"""
47+
48+
@abstractmethod
49+
def scope_spec(self) -> CreatorSpec[TScopeRow]:
50+
"""Single-table spec for the scope row."""
51+
raise NotImplementedError
52+
53+
@abstractmethod
54+
def extract_scope_context(self, scope_row: TScopeRow) -> ScopeContext:
55+
"""Derive ``(scope_type, scope_id)`` from the just-inserted scope row.
56+
57+
The orchestrator uses this to look up matching role presets and to populate
58+
``scope_id`` on derived permission and association rows.
59+
"""
60+
raise NotImplementedError
61+
62+
@abstractmethod
63+
def parent_association_specs(
64+
self,
65+
scope_row: TScopeRow,
66+
) -> Sequence[CreatorSpec[AssociationScopesEntitiesRow]]:
67+
"""Parent-scope mapping rows (e.g. project under domain).
68+
69+
Return ``[]`` if the scope type has no parent.
70+
"""
71+
raise NotImplementedError
72+
73+
74+
@dataclass
75+
class ScopeCreator[TScopeRow: Base]:
76+
"""Bundles a scope-creator spec for ``ScopeWriteOps.create_scope``."""
77+
78+
spec: ScopeCreatorSpec[TScopeRow]
79+
80+
81+
@dataclass
82+
class ScopeCreatorResult[TScopeRow: Base]:
83+
"""Outcome of a successful scope provisioning.
84+
85+
Only surfaces the freshly-inserted scope row and the roles that were
86+
instantiated from active role presets. Auxiliary rows (permissions,
87+
role-to-scope associations, parent-scope associations) are still
88+
inserted by the orchestrator but are not returned.
89+
"""
90+
91+
scope_row: TScopeRow
92+
role_rows: list[RoleRow]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Composite spec for race-free scope teardown.
2+
3+
Mirror of :mod:`scope_creator`. A scope-purger spec coordinates the inverse
4+
sequence: drop scope-bound permission rows, drop the scope's association rows,
5+
and finally drop the scope row itself.
6+
7+
Roles are intentionally left alone — role lifecycle is independent of scope
8+
lifecycle, and a role may be reused or reassigned after a scope is gone.
9+
10+
The spec itself owns no table; each returned sub-spec targets exactly one table.
11+
Orchestration lives in :meth:`ScopeWriteOps.purge_scope`.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from abc import ABC, abstractmethod
17+
from dataclasses import dataclass
18+
from uuid import UUID
19+
20+
from ai.backend.manager.models.base import Base
21+
from ai.backend.manager.models.rbac_models.association_scopes_entities import (
22+
AssociationScopesEntitiesRow,
23+
)
24+
from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow
25+
26+
from .purger import BatchPurgerSpec
27+
28+
29+
class ScopePurgerSpec[TScopeRow: Base](ABC):
30+
"""Coordinator for purging a scope and the scope-bound RBAC rows it leaves behind.
31+
32+
Subclass per scope type. Each returned sub-spec targets exactly one table.
33+
"""
34+
35+
@abstractmethod
36+
def scope_row_class(self) -> type[TScopeRow]:
37+
"""ORM class of the scope row being purged."""
38+
raise NotImplementedError
39+
40+
@abstractmethod
41+
def scope_pk_value(self) -> UUID | str:
42+
"""Primary-key value of the scope row to delete."""
43+
raise NotImplementedError
44+
45+
@abstractmethod
46+
def permissions_purge_spec(self) -> BatchPurgerSpec[PermissionRow]:
47+
"""Subquery selecting permission rows pinned to this scope."""
48+
raise NotImplementedError
49+
50+
@abstractmethod
51+
def associations_purge_spec(self) -> BatchPurgerSpec[AssociationScopesEntitiesRow]:
52+
"""Single subquery selecting every association row to drop for this scope.
53+
54+
Multiple conditions (scope referenced as scope, scope referenced as entity, ...)
55+
should be OR-combined into one ``WHERE`` clause; ``execute_batch_purger`` will
56+
chunk the resulting set into batches automatically.
57+
"""
58+
raise NotImplementedError
59+
60+
61+
@dataclass
62+
class ScopePurger[TScopeRow: Base]:
63+
"""Bundles a scope-purger spec for ``ScopeWriteOps.purge_scope``."""
64+
65+
spec: ScopePurgerSpec[TScopeRow]
66+
67+
68+
@dataclass
69+
class ScopePurgerResult[TScopeRow: Base]:
70+
"""Outcome of a scope teardown."""
71+
72+
scope_row: TScopeRow | None
73+
deleted_permission_count: int
74+
deleted_association_count: int

0 commit comments

Comments
 (0)