Skip to content

Commit 269eb29

Browse files
jopemachineclaude
andcommitted
feat(BA-6859): bind app_config fragments to their RBAC scope on write (repository layer)
Run the app_config_fragment repository on RBACOpsProvider and make every fragment write flow through the RBAC scope machinery, branch-free: - The shared RBAC creator/unbinder specs now model *global-scoped* entities: RBACEntityCreator.scope_ref and RBACScopeEntityUnbinder.scope_ref accept None, meaning the entity lives outside the RBAC scope hierarchy and carries no scope association. - AppConfigScopeType.to_rbac_element_type() maps a fragment scope to its RBAC scope element (public -> None), next to its to_rbac_scope_type sibling. - create binds a user/domain fragment to its scope via association_scopes_entities in the same tx; purge unbinds it through RBACWriteOps.unbind_scope_entities (implemented directly on the ops provider — the standalone execute_rbac_* helpers are transitional), deleting the row and its association atomically. A public fragment takes the same single code path with scope_ref=None. - Registers APP_CONFIG_FRAGMENT as an owner-accessible RBAC resource type. - Repository/db_source create now takes AppConfigFragmentCreatorSpec directly. Data-layer foundation for RBAC-gating fragment writes; service-layer validators + DI registration follow (BA-6860). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 915ef13 commit 269eb29

13 files changed

Lines changed: 314 additions & 61 deletions

File tree

changes/12801.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Bind AppConfig fragments to their RBAC scope on write (repository layer): a `user` / `domain` fragment is associated to its RBAC scope on create and unbound on purge (`public` maps to GLOBAL and carries none), so fragment write authorization can be enforced by RBAC. The allow list becomes a read allowlist (registration + `rank`) while writes move to RBAC (BEP-1052).

src/ai/backend/common/data/app_config/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import enum
66

7-
from ai.backend.common.data.permission.types import ScopeType
7+
from ai.backend.common.data.permission.types import RBACElementType, ScopeType
88

99
__all__ = ("AppConfigScopeType",)
1010

@@ -34,6 +34,21 @@ def to_rbac_scope_type(self) -> ScopeType:
3434
case AppConfigScopeType.USER:
3535
return ScopeType.USER
3636

37+
def to_rbac_element_type(self) -> RBACElementType | None:
38+
"""The RBAC scope element a fragment at this scope belongs to.
39+
40+
``public`` maps to the global scope, which has no RBAC scope element — a public
41+
fragment is *global-scoped* (no scope association; superadmin-only writes), so it
42+
returns ``None``.
43+
"""
44+
match self:
45+
case AppConfigScopeType.PUBLIC:
46+
return None
47+
case AppConfigScopeType.DOMAIN:
48+
return RBACElementType.DOMAIN
49+
case AppConfigScopeType.USER:
50+
return RBACElementType.USER
51+
3752
def to_rbac_scope_id(self, scope_id: str) -> str:
3853
"""The RBAC scope id for a write at this fragment scope.
3954

src/ai/backend/common/data/permission/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ def _resource_types(cls) -> set[EntityType]:
270270
cls.ARTIFACT,
271271
cls.ARTIFACT_REGISTRY,
272272
cls.APP_CONFIG,
273+
cls.APP_CONFIG_FRAGMENT,
273274
cls.NOTIFICATION_CHANNEL,
274275
cls.NOTIFICATION_RULE,
275276
cls.MODEL_DEPLOYMENT,

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

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import sqlalchemy as sa
88

9+
from ai.backend.common.data.permission.types import RBACElementType
910
from ai.backend.common.exception import BackendAIError
1011
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
1112
from ai.backend.common.metrics.metric import DomainType, LayerType
@@ -25,19 +26,26 @@
2526
from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions
2627
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
2728
from ai.backend.manager.models.scopes import SearchScope
29+
from ai.backend.manager.repositories.app_config_fragment.creators import (
30+
AppConfigFragmentCreatorSpec,
31+
)
32+
from ai.backend.manager.repositories.app_config_fragment.scope_binders import (
33+
AppConfigFragmentScopeUnbinder,
34+
fragment_rbac_scope_ref,
35+
)
2836
from ai.backend.manager.repositories.app_config_fragment.types import (
2937
AppConfigScopeArguments,
3038
)
3139
from ai.backend.manager.repositories.base import (
3240
BatchQuerier,
3341
BulkCreator,
34-
Creator,
3542
NoPagination,
3643
Purger,
3744
Querier,
3845
Updater,
3946
)
40-
from ai.backend.manager.repositories.ops import DBOpsProvider
47+
from ai.backend.manager.repositories.base.rbac.entity_creator import RBACEntityCreator
48+
from ai.backend.manager.repositories.ops.rbac.provider import RBACOpsProvider
4149

4250
__all__ = ("AppConfigFragmentDBSource",)
4351

@@ -64,19 +72,27 @@
6472
class AppConfigFragmentDBSource:
6573
"""Database source for app config fragment operations."""
6674

67-
_ops: DBOpsProvider
75+
_ops: RBACOpsProvider
6876

69-
def __init__(self, ops_provider: DBOpsProvider) -> None:
77+
def __init__(self, ops_provider: RBACOpsProvider) -> None:
7078
self._ops = ops_provider
7179

7280
@app_config_fragment_db_source_resilience.apply()
73-
async def create(self, creator: Creator[AppConfigFragmentRow]) -> AppConfigFragmentData:
74-
# The FK to the allow-list is the gate: inserting a fragment with no
75-
# allow-list row for its ``(config_name, scope_type)`` raises
76-
# ``AppConfigFragmentWriteNotAllowed`` (see the spec's integrity checks).
81+
async def create(self, spec: AppConfigFragmentCreatorSpec) -> AppConfigFragmentData:
82+
# The FK to the allow-list gates *existence* (a missing allow-list row for the
83+
# ``(config_name, scope_type)`` raises ``AppConfigFragmentWriteNotAllowed``). Create
84+
# also binds the fragment to its owning RBAC scope via
85+
# ``association_scopes_entities`` (same tx) so a later update/purge can resolve its
86+
# scope for the RBAC write check; a ``public`` fragment is global-scoped
87+
# (``scope_ref`` is ``None``) and carries no association.
88+
rbac_creator = RBACEntityCreator(
89+
spec=spec,
90+
element_type=RBACElementType.APP_CONFIG_FRAGMENT,
91+
scope_ref=fragment_rbac_scope_ref(spec.scope_type, spec.scope_id),
92+
)
7793
async with self._ops.write_ops() as w:
78-
created = await w.create(creator)
79-
return created.row.to_data()
94+
created = await w.bulk_create_scoped([rbac_creator])
95+
return created.rows[0].to_data()
8096

8197
@app_config_fragment_db_source_resilience.apply()
8298
async def get_by_id(self, fragment_id: AppConfigFragmentID) -> AppConfigFragmentData:
@@ -99,12 +115,23 @@ async def update(self, updater: Updater[AppConfigFragmentRow]) -> AppConfigFragm
99115

100116
@app_config_fragment_db_source_resilience.apply()
101117
async def purge(self, purger: Purger[AppConfigFragmentRow]) -> AppConfigFragmentData:
102-
# No write-gate here — see ``update``.
118+
# A fragment is a config bound at a scope, so purging is an RBAC *unbind*: the row
119+
# and its scope association are deleted atomically (the association table has no FK
120+
# cascade). A public fragment is global-scoped and simply has no association. The
121+
# row is fetched first to resolve its scope and to return its data.
103122
async with self._ops.write_ops() as w:
104-
result = await w.purge(purger)
105-
if result is None:
123+
found = await w.query(Querier(row_class=AppConfigFragmentRow, pk_value=purger.pk_value))
124+
if found is None:
106125
raise AppConfigFragmentNotFound(f"App config fragment {purger.pk_value} not found")
107-
return result.row.to_data()
126+
data = found.row.to_data()
127+
await w.unbind_scope_entities(
128+
AppConfigFragmentScopeUnbinder(
129+
fragment_id=data.id,
130+
fragment_scope_type=data.scope_type,
131+
fragment_scope_id=data.scope_id,
132+
)
133+
)
134+
return data
108135

109136
@app_config_fragment_db_source_resilience.apply()
110137
async def bulk_create(

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ai.backend.manager.repositories.app_config_fragment.repository import (
55
AppConfigFragmentRepository,
66
)
7+
from ai.backend.manager.repositories.ops.rbac.provider import RBACOpsProvider
78
from ai.backend.manager.repositories.types import RepositoryArgs
89

910

@@ -13,6 +14,8 @@ class AppConfigFragmentRepositories:
1314

1415
@classmethod
1516
def create(cls, args: RepositoryArgs) -> Self:
17+
# Fragment writes bind the fragment to its RBAC scope (see the db_source), so the
18+
# repository runs on the RBAC-scoped ops provider rather than the plain one.
1619
return cls(
17-
repository=AppConfigFragmentRepository(args.ops_provider),
20+
repository=AppConfigFragmentRepository(RBACOpsProvider(args.db)),
1821
)

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
)
1616
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
1717
from ai.backend.manager.models.scopes import SearchScope
18+
from ai.backend.manager.repositories.app_config_fragment.creators import (
19+
AppConfigFragmentCreatorSpec,
20+
)
1821
from ai.backend.manager.repositories.app_config_fragment.db_source import (
1922
AppConfigFragmentDBSource,
2023
)
@@ -24,11 +27,10 @@
2427
from ai.backend.manager.repositories.base import (
2528
BatchQuerier,
2629
BulkCreator,
27-
Creator,
2830
Purger,
2931
Updater,
3032
)
31-
from ai.backend.manager.repositories.ops import DBOpsProvider
33+
from ai.backend.manager.repositories.ops.rbac.provider import RBACOpsProvider
3234

3335
__all__ = ("AppConfigFragmentRepository",)
3436

@@ -57,12 +59,12 @@ class AppConfigFragmentRepository:
5759

5860
_db_source: AppConfigFragmentDBSource
5961

60-
def __init__(self, ops_provider: DBOpsProvider) -> None:
62+
def __init__(self, ops_provider: RBACOpsProvider) -> None:
6163
self._db_source = AppConfigFragmentDBSource(ops_provider)
6264

6365
@app_config_fragment_repository_resilience.apply()
64-
async def create(self, creator: Creator[AppConfigFragmentRow]) -> AppConfigFragmentData:
65-
return await self._db_source.create(creator)
66+
async def create(self, spec: AppConfigFragmentCreatorSpec) -> AppConfigFragmentData:
67+
return await self._db_source.create(spec)
6668

6769
@app_config_fragment_repository_resilience.apply()
6870
async def get_by_id(self, fragment_id: AppConfigFragmentID) -> AppConfigFragmentData:
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""RBAC scope binding helpers for app config fragments."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
from dataclasses import dataclass
7+
from typing import override
8+
9+
import sqlalchemy as sa
10+
11+
from ai.backend.common.data.app_config.types import AppConfigScopeType
12+
from ai.backend.common.data.permission.types import RBACElementType
13+
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
14+
from ai.backend.manager.data.permission.types import RBACElementRef
15+
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
16+
from ai.backend.manager.repositories.base.purger import BatchPurgerSpec
17+
from ai.backend.manager.repositories.base.rbac.scope_unbinder import RBACScopeEntityUnbinder
18+
19+
20+
def fragment_rbac_scope_ref(scope_type: AppConfigScopeType, scope_id: str) -> RBACElementRef | None:
21+
"""The RBAC scope a fragment belongs to; ``None`` for global-scoped (public) fragments."""
22+
element = scope_type.to_rbac_element_type()
23+
if element is None:
24+
return None
25+
return RBACElementRef(element, scope_id)
26+
27+
28+
@dataclass
29+
class AppConfigFragmentByIdPurgerSpec(BatchPurgerSpec[AppConfigFragmentRow]):
30+
"""Select a single fragment row (by id) for deletion."""
31+
32+
fragment_id: AppConfigFragmentID
33+
34+
@override
35+
def build_subquery(self) -> sa.sql.Select[tuple[AppConfigFragmentRow]]:
36+
return sa.select(AppConfigFragmentRow).where(AppConfigFragmentRow.id == self.fragment_id)
37+
38+
39+
@dataclass
40+
class AppConfigFragmentScopeUnbinder(RBACScopeEntityUnbinder[AppConfigFragmentRow]):
41+
"""Unbind (purge) one fragment from its owning scope.
42+
43+
A fragment is a config bound at a scope, so purging it is an unbind: the fragment row
44+
and its RBAC scope association are deleted atomically. A ``public`` fragment is
45+
global-scoped (``scope_ref`` is ``None``), so only its row is deleted — it never had
46+
an association.
47+
"""
48+
49+
fragment_id: AppConfigFragmentID
50+
fragment_scope_type: AppConfigScopeType
51+
fragment_scope_id: str
52+
53+
@override
54+
def build_purger_spec(self) -> BatchPurgerSpec[AppConfigFragmentRow]:
55+
return AppConfigFragmentByIdPurgerSpec(fragment_id=self.fragment_id)
56+
57+
@property
58+
@override
59+
def entity_type(self) -> RBACElementType:
60+
return RBACElementType.APP_CONFIG_FRAGMENT
61+
62+
@property
63+
@override
64+
def scope_ref(self) -> RBACElementRef | None:
65+
return fragment_rbac_scope_ref(self.fragment_scope_type, self.fragment_scope_id)
66+
67+
@property
68+
@override
69+
def entity_ids(self) -> Sequence[str] | None:
70+
return [str(self.fragment_id)]

src/ai/backend/manager/repositories/base/rbac/entity_creator.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,21 @@ class RBACEntityCreator[TRow: Base]:
3737
"""Creator for a single entity with scope associations for RBAC.
3838
3939
Creates an entity row and associates it with one or more permission scopes.
40-
The primary scope is required; additional scopes are optional.
4140
4241
Attributes:
4342
spec: CreatorSpec implementation defining the row to create.
4443
element_type: The RBAC element type for this entity.
4544
scope_ref: Primary scope reference (scope_type + scope_id) for this entity.
45+
``None`` marks a *global-scoped* entity — one living outside the RBAC scope
46+
hierarchy (e.g. a public app-config fragment) — which carries no scope
47+
association (only a superadmin reaches it, bypassing scope resolution).
4648
additional_scope_refs: Additional scope references for multi-scope entities.
4749
relation_type: The relation type for the scope-entity association. Defaults to AUTO.
4850
"""
4951

5052
spec: CreatorSpec[TRow]
5153
element_type: RBACElementType
52-
scope_ref: RBACElementRef
54+
scope_ref: RBACElementRef | None
5355
additional_scope_refs: Sequence[RBACElementRef] = field(default_factory=list)
5456
relation_type: RelationType = RelationType.AUTO
5557

@@ -106,7 +108,10 @@ async def execute_rbac_entity_creator[TRow: Base](
106108
instance_state = inspect(row)
107109
pk_value = instance_state.identity[0]
108110
entity_type = creator.element_type.to_entity_type()
109-
all_scope_refs = [creator.scope_ref, *creator.additional_scope_refs]
111+
# A None primary scope marks a global-scoped entity: no association row.
112+
all_scope_refs = [
113+
ref for ref in (creator.scope_ref, *creator.additional_scope_refs) if ref is not None
114+
]
110115
associations = [
111116
AssociationScopesEntitiesRow(
112117
scope_type=scope_ref.element_type.to_scope_type(),
@@ -117,7 +122,8 @@ async def execute_rbac_entity_creator[TRow: Base](
117122
)
118123
for scope_ref in all_scope_refs
119124
]
120-
await bulk_insert_on_conflict_do_nothing(db_sess, associations)
125+
if associations:
126+
await bulk_insert_on_conflict_do_nothing(db_sess, associations)
121127

122128
return RBACEntityCreatorResult(row=row)
123129

@@ -255,11 +261,14 @@ async def execute_rbac_entity_creators[TRow: Base](
255261
match_integrity_error(parsed, checks)
256262

257263
# 3. Collect all associations from each creator's scope refs
264+
# (a None primary scope marks a global-scoped entity: no association row)
258265
associations: list[AssociationScopesEntitiesRow] = []
259266
for creator, row in zip(creators, rows, strict=True):
260267
pk_value = inspect(row).identity[0]
261268
entity_type = creator.element_type.to_entity_type()
262-
all_scope_refs = [creator.scope_ref, *creator.additional_scope_refs]
269+
all_scope_refs = [
270+
ref for ref in (creator.scope_ref, *creator.additional_scope_refs) if ref is not None
271+
]
263272
for scope_ref in all_scope_refs:
264273
associations.append(
265274
AssociationScopesEntitiesRow(
@@ -270,6 +279,7 @@ async def execute_rbac_entity_creators[TRow: Base](
270279
relation_type=creator.relation_type,
271280
),
272281
)
273-
await bulk_insert_on_conflict_do_nothing(db_sess, associations)
282+
if associations:
283+
await bulk_insert_on_conflict_do_nothing(db_sess, associations)
274284

275285
return RBACBulkEntityCreatorResult(rows=rows)

src/ai/backend/manager/repositories/base/rbac/scope_unbinder.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ def entity_type(self) -> RBACElementType:
5353

5454
@property
5555
@abstractmethod
56-
def scope_ref(self) -> RBACElementRef:
57-
"""RBAC element ref for the scope to unbind entities from."""
56+
def scope_ref(self) -> RBACElementRef | None:
57+
"""RBAC element ref for the scope to unbind entities from.
58+
59+
``None`` marks global-scoped entities (outside the RBAC scope hierarchy):
60+
only the business rows are deleted — there is no scope association to remove.
61+
"""
5862
raise NotImplementedError
5963

6064
@property
@@ -110,6 +114,12 @@ async def execute_rbac_scope_entity_unbinder[TRow: Base](
110114
db_sess, BatchPurger(spec=unbinder.build_purger_spec())
111115
)
112116
scope_ref = unbinder.scope_ref
117+
if scope_ref is None:
118+
# Global-scoped entities carry no scope association — nothing more to delete.
119+
return RBACUnbinderResult(
120+
deleted_count=purge_result.deleted_count,
121+
association_rows=[],
122+
)
113123
where_clauses = [
114124
AssociationScopesEntitiesRow.entity_type == unbinder.entity_type.to_entity_type(),
115125
AssociationScopesEntitiesRow.scope_type == scope_ref.element_type.to_scope_type(),

0 commit comments

Comments
 (0)