Skip to content

Commit d4c9971

Browse files
jopemachineclaude
andauthored
feat(BA-6554): add app_config_fragment service layer (#12358)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6561374 commit d4c9971

26 files changed

Lines changed: 1138 additions & 66 deletions

File tree

changes/12358.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the app_config_fragment service layer (admin create / update / purge with the allow-list write-gate).

docs/manager/graphql-reference/supergraph.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15256,6 +15256,7 @@ enum RBACElementType
1525615256
APP_CONFIG @join__enumValue(graph: STRAWBERRY)
1525715257
APP_CONFIG_DEFINITION @join__enumValue(graph: STRAWBERRY)
1525815258
APP_CONFIG_ALLOW_LIST @join__enumValue(graph: STRAWBERRY)
15259+
APP_CONFIG_FRAGMENT @join__enumValue(graph: STRAWBERRY)
1525915260
MODEL_CARD @join__enumValue(graph: STRAWBERRY)
1526015261
RESOURCE_PRESET @join__enumValue(graph: STRAWBERRY)
1526115262
USER_RESOURCE_POLICY @join__enumValue(graph: STRAWBERRY)

docs/manager/graphql-reference/v2-schema.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10258,6 +10258,7 @@ enum RBACElementType {
1025810258
APP_CONFIG
1025910259
APP_CONFIG_DEFINITION
1026010260
APP_CONFIG_ALLOW_LIST
10261+
APP_CONFIG_FRAGMENT
1026110262
MODEL_CARD
1026210263
RESOURCE_PRESET
1026310264
USER_RESOURCE_POLICY

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class EntityType(enum.StrEnum):
104104
AGENT = "agent"
105105
APP_CONFIG_DEFINITION = "app_config_definition"
106106
APP_CONFIG_ALLOW_LIST = "app_config_allow_list"
107+
APP_CONFIG_FRAGMENT = "app_config_fragment"
107108
AUTH = "auth"
108109
PROJECT_ADMIN_PAGE = "project_admin_page"
109110
DOMAIN_ADMIN_PAGE = "domain_admin_page"
@@ -408,6 +409,7 @@ class RBACElementType(enum.StrEnum):
408409
APP_CONFIG = "app_config"
409410
APP_CONFIG_DEFINITION = "app_config_definition"
410411
APP_CONFIG_ALLOW_LIST = "app_config_allow_list"
412+
APP_CONFIG_FRAGMENT = "app_config_fragment"
411413
MODEL_CARD = "model_card"
412414

413415
# === Root-query-enabled entities (superadmin-only) ===

src/ai/backend/common/dto/manager/v2/rbac/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class RBACElementTypeDTO(StrEnum):
9999
APP_CONFIG = "app_config"
100100
APP_CONFIG_DEFINITION = "app_config_definition"
101101
APP_CONFIG_ALLOW_LIST = "app_config_allow_list"
102+
APP_CONFIG_FRAGMENT = "app_config_fragment"
102103
MODEL_CARD = "model_card"
103104

104105
# Root-query-enabled entities (superadmin-only)

src/ai/backend/manager/api/gql/rbac/types/permission.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ async def scope(
232232
| RBACElementType.APP_CONFIG
233233
| RBACElementType.APP_CONFIG_DEFINITION
234234
| RBACElementType.APP_CONFIG_ALLOW_LIST
235+
| RBACElementType.APP_CONFIG_FRAGMENT
235236
| RBACElementType.RESOURCE_PRESET
236237
| RBACElementType.USER_RESOURCE_POLICY
237238
| RBACElementType.KEYPAIR_RESOURCE_POLICY

src/ai/backend/manager/errors/app_config.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from __future__ import annotations
22

3-
from ai.backend.manager.errors.common import ObjectNotFound
3+
from ai.backend.manager.errors.common import GenericForbidden, ObjectNotFound
44

55
__all__ = (
66
"AppConfigAllowListNotFound",
77
"AppConfigDefinitionNotFound",
88
"AppConfigFragmentNotFound",
9+
"AppConfigFragmentWriteNotAllowed",
910
)
1011

1112

@@ -22,3 +23,14 @@ class AppConfigAllowListNotFound(ObjectNotFound):
2223
class AppConfigFragmentNotFound(ObjectNotFound):
2324
error_type = "https://api.backend.ai/probs/app-config-fragment-not-found"
2425
object_name = "app config fragment"
26+
27+
28+
class AppConfigFragmentWriteNotAllowed(GenericForbidden):
29+
"""A fragment write was rejected by the write-gate.
30+
31+
Raised when the target ``config_name`` is not registered, or no app_config_allow_list
32+
row exists for the target ``(config_name, scope_type)`` pair.
33+
"""
34+
35+
error_type = "https://api.backend.ai/probs/app-config-fragment-write-not-allowed"
36+
error_title = "App config fragment write is not allowed for this config/scope."

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,24 @@
1616
AppConfigFragmentData,
1717
AppConfigFragmentSearchResult,
1818
)
19-
from ai.backend.manager.errors.app_config import AppConfigFragmentNotFound
19+
from ai.backend.manager.errors.app_config import (
20+
AppConfigFragmentNotFound,
21+
AppConfigFragmentWriteNotAllowed,
22+
)
23+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
2024
from ai.backend.manager.models.app_config_definition.row import AppConfigDefinitionRow
2125
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
2226
from ai.backend.manager.models.scopes import SearchScope
2327
from ai.backend.manager.repositories.app_config_fragment.creators import (
2428
AppConfigFragmentCreatorSpec,
2529
)
26-
from ai.backend.manager.repositories.base import BatchQuerier, Purger, Querier, Updater
30+
from ai.backend.manager.repositories.base import (
31+
BatchQuerier,
32+
ExistsQuerier,
33+
Purger,
34+
Querier,
35+
Updater,
36+
)
2737
from ai.backend.manager.repositories.base.creator import NextValuePolicy
2838
from ai.backend.manager.repositories.ops import DBOpsProvider
2939

@@ -61,7 +71,11 @@ def __init__(self, ops_provider: DBOpsProvider) -> None:
6171
self._ops = ops_provider
6272

6373
@app_config_fragment_db_source_resilience.apply()
64-
async def create(self, spec: AppConfigFragmentCreatorSpec) -> AppConfigFragmentData:
74+
async def create(
75+
self,
76+
spec: AppConfigFragmentCreatorSpec,
77+
only_if: ExistsQuerier[AppConfigAllowListRow],
78+
) -> AppConfigFragmentData:
6579
policy = NextValuePolicy(
6680
column=AppConfigFragmentRow.rank,
6781
scope_condition=lambda: AppConfigFragmentRow.config_name == spec.config_name,
@@ -70,7 +84,14 @@ async def create(self, spec: AppConfigFragmentCreatorSpec) -> AppConfigFragmentD
7084
),
7185
gap=RANK_GAP,
7286
)
87+
# ``only_if`` (built by the caller) and the write run in one transaction, so the gate
88+
# check and the write commit atomically — no check-then-write race.
7389
async with self._ops.write_ops() as w:
90+
if not await w.exists(only_if):
91+
raise AppConfigFragmentWriteNotAllowed(
92+
f"Writing app config {spec.config_name!r} at scope "
93+
f"{spec.scope_type.value!r} is not allowed."
94+
)
7495
created = await w.create_with_next_value(policy, spec)
7596
return created.row.to_data()
7697

@@ -83,16 +104,36 @@ async def get_by_id(self, fragment_id: AppConfigFragmentID) -> AppConfigFragment
83104
return result.row.to_data()
84105

85106
@app_config_fragment_db_source_resilience.apply()
86-
async def update(self, updater: Updater[AppConfigFragmentRow]) -> AppConfigFragmentData:
107+
async def update(
108+
self,
109+
updater: Updater[AppConfigFragmentRow],
110+
only_if: ExistsQuerier[AppConfigAllowListRow],
111+
) -> AppConfigFragmentData:
112+
# Gate first, then write — both in one transaction so the check and the write commit
113+
# atomically. A missing fragment surfaces as the update returning None below.
87114
async with self._ops.write_ops() as w:
115+
if not await w.exists(only_if):
116+
raise AppConfigFragmentWriteNotAllowed(
117+
f"Writing app config fragment {updater.pk_value} is not allowed."
118+
)
88119
result = await w.update(updater)
89120
if result is None:
90121
raise AppConfigFragmentNotFound(f"App config fragment {updater.pk_value} not found")
91122
return result.row.to_data()
92123

93124
@app_config_fragment_db_source_resilience.apply()
94-
async def purge(self, purger: Purger[AppConfigFragmentRow]) -> AppConfigFragmentData:
125+
async def purge(
126+
self,
127+
purger: Purger[AppConfigFragmentRow],
128+
only_if: ExistsQuerier[AppConfigAllowListRow],
129+
) -> AppConfigFragmentData:
130+
# Gate first, then write — both in one transaction so the check and the write commit
131+
# atomically. A missing fragment surfaces as the purge returning None below.
95132
async with self._ops.write_ops() as w:
133+
if not await w.exists(only_if):
134+
raise AppConfigFragmentWriteNotAllowed(
135+
f"Writing app config fragment {purger.pk_value} is not allowed."
136+
)
96137
result = await w.purge(purger)
97138
if result is None:
98139
raise AppConfigFragmentNotFound(f"App config fragment {purger.pk_value} not found")

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AppConfigFragmentData,
1313
AppConfigFragmentSearchResult,
1414
)
15+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
1516
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
1617
from ai.backend.manager.models.scopes import SearchScope
1718
from ai.backend.manager.repositories.app_config_fragment.creators import (
@@ -20,7 +21,7 @@
2021
from ai.backend.manager.repositories.app_config_fragment.db_source import (
2122
AppConfigFragmentDBSource,
2223
)
23-
from ai.backend.manager.repositories.base import BatchQuerier, Purger, Updater
24+
from ai.backend.manager.repositories.base import BatchQuerier, ExistsQuerier, Purger, Updater
2425
from ai.backend.manager.repositories.ops import DBOpsProvider
2526

2627
__all__ = ("AppConfigFragmentRepository",)
@@ -54,20 +55,32 @@ def __init__(self, ops_provider: DBOpsProvider) -> None:
5455
self._db_source = AppConfigFragmentDBSource(ops_provider)
5556

5657
@app_config_fragment_repository_resilience.apply()
57-
async def create(self, spec: AppConfigFragmentCreatorSpec) -> AppConfigFragmentData:
58-
return await self._db_source.create(spec)
58+
async def create(
59+
self,
60+
spec: AppConfigFragmentCreatorSpec,
61+
only_if: ExistsQuerier[AppConfigAllowListRow],
62+
) -> AppConfigFragmentData:
63+
return await self._db_source.create(spec, only_if)
5964

6065
@app_config_fragment_repository_resilience.apply()
6166
async def get_by_id(self, fragment_id: AppConfigFragmentID) -> AppConfigFragmentData:
6267
return await self._db_source.get_by_id(fragment_id)
6368

6469
@app_config_fragment_repository_resilience.apply()
65-
async def update(self, updater: Updater[AppConfigFragmentRow]) -> AppConfigFragmentData:
66-
return await self._db_source.update(updater)
70+
async def update(
71+
self,
72+
updater: Updater[AppConfigFragmentRow],
73+
only_if: ExistsQuerier[AppConfigAllowListRow],
74+
) -> AppConfigFragmentData:
75+
return await self._db_source.update(updater, only_if)
6776

6877
@app_config_fragment_repository_resilience.apply()
69-
async def purge(self, purger: Purger[AppConfigFragmentRow]) -> AppConfigFragmentData:
70-
return await self._db_source.purge(purger)
78+
async def purge(
79+
self,
80+
purger: Purger[AppConfigFragmentRow],
81+
only_if: ExistsQuerier[AppConfigAllowListRow],
82+
) -> AppConfigFragmentData:
83+
return await self._db_source.purge(purger, only_if)
7184

7285
@app_config_fragment_repository_resilience.apply()
7386
async def admin_search(self, querier: BatchQuerier) -> AppConfigFragmentSearchResult:

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

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,67 @@
88

99
import sqlalchemy as sa
1010

11-
from ai.backend.manager.errors.app_config import AppConfigDefinitionNotFound
12-
from ai.backend.manager.models.app_config_definition.row import AppConfigDefinitionRow
11+
from ai.backend.common.data.app_config.types import AppConfigScopeType
12+
from ai.backend.common.identifier.domain import DomainID
13+
from ai.backend.common.identifier.user import UserID
1314
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
1415
from ai.backend.manager.models.clauses import QueryCondition
1516
from ai.backend.manager.models.scopes import ExistenceCheck, SearchScope
1617

17-
__all__ = ("ConfigNameSearchScope",)
18+
__all__ = (
19+
"DomainAppConfigFragmentSearchScope",
20+
"UserAppConfigFragmentSearchScope",
21+
)
1822

1923

2024
@dataclass(frozen=True)
21-
class ConfigNameSearchScope(SearchScope):
22-
"""Fragments belonging to a single ``config_name``.
25+
class DomainAppConfigFragmentSearchScope(SearchScope):
26+
"""Fragments written at one domain scope (``scope_type == domain``, ``scope_id == domain_id``).
2327
24-
One scope = all fragments registered under one app config name, regardless of their
25-
``scope_type`` (public / domain / user). The existence check rejects an unregistered
26-
``config_name`` up front so a scoped search cannot silently return nothing.
28+
One scope = one item of a scoped fragment query; the repository combines multiple
29+
scopes with ``OR``. ``existence_checks`` is empty by ``SearchableActionTarget``
30+
convention — RBAC already gates scope reachability.
2731
"""
2832

29-
config_name: str
33+
domain_id: DomainID
3034

3135
@override
3236
def to_condition(self) -> QueryCondition:
33-
config_name = self.config_name
37+
domain_id = self.domain_id
3438

3539
def inner() -> sa.sql.expression.ColumnElement[bool]:
36-
return AppConfigFragmentRow.config_name == config_name
40+
return sa.and_(
41+
AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN,
42+
AppConfigFragmentRow.scope_id == str(domain_id),
43+
)
3744

3845
return inner
3946

4047
@property
4148
@override
4249
def existence_checks(self) -> Sequence[ExistenceCheck[Any]]:
43-
return (
44-
ExistenceCheck(
45-
column=AppConfigDefinitionRow.config_name,
46-
value=self.config_name,
47-
error=AppConfigDefinitionNotFound(
48-
f"App config definition {self.config_name!r} not found"
49-
),
50-
),
51-
)
50+
return ()
51+
52+
53+
@dataclass(frozen=True)
54+
class UserAppConfigFragmentSearchScope(SearchScope):
55+
"""Fragments written at one user scope (``scope_type == user``, ``scope_id == user_id``)."""
56+
57+
user_id: UserID
58+
59+
@override
60+
def to_condition(self) -> QueryCondition:
61+
user_id = self.user_id
62+
63+
def inner() -> sa.sql.expression.ColumnElement[bool]:
64+
return sa.and_(
65+
AppConfigFragmentRow.scope_type == AppConfigScopeType.USER,
66+
AppConfigFragmentRow.scope_id == str(user_id),
67+
)
68+
69+
return inner
70+
71+
@property
72+
@override
73+
def existence_checks(self) -> Sequence[ExistenceCheck[Any]]:
74+
return ()

0 commit comments

Comments
 (0)