Skip to content

Commit 36f8cfa

Browse files
jopemachineclaude
andcommitted
feat(BA-6836): authorize fragment writes via allow_list write_access tier
Enforce AppConfig's standalone write authorization: each fragment write is checked against the write_access tier of the target (config_name, scope_type) allow_list entry (AppConfigAccessLevel.is_satisfied_by) — no RBAC validator. A user may manage their own user-scope fragment; public/domain default to admin-only; superadmin passes any tier. create authorizes from the creator spec; update/purge load the fragment first; bulk writes authorize per item (unauthorized items become partial failures). Fragment write routes switch from superadmin_required to auth_required; the service gains the allow_list repository (by_config_and_scope) for the tier lookup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 28509bb commit 36f8cfa

15 files changed

Lines changed: 617 additions & 121 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import enum
66

77
from ai.backend.common.data.permission.types import ScopeType
8+
from ai.backend.common.data.user.types import UserData
89

910
__all__ = ("AppConfigScopeType", "AppConfigAccessLevel")
1011

@@ -27,6 +28,39 @@ class AppConfigAccessLevel(enum.StrEnum):
2728
OWNER = "owner"
2829
ADMIN = "admin"
2930

31+
def is_satisfied_by(
32+
self, user: UserData | None, scope_type: AppConfigScopeType, scope_id: str
33+
) -> bool:
34+
"""Whether ``user`` meets this access tier for a fragment at ``(scope_type, scope_id)``.
35+
36+
Anonymous (``user is None``) satisfies only ``public``. A superadmin satisfies every
37+
tier. ``owner`` is instance-relative: the user itself for ``user`` scope, a domain
38+
admin for ``domain`` scope; ``public`` scope has no owner.
39+
"""
40+
if user is None:
41+
return self is AppConfigAccessLevel.PUBLIC
42+
if user.is_superadmin:
43+
return True
44+
match self:
45+
case AppConfigAccessLevel.PUBLIC:
46+
return True
47+
case AppConfigAccessLevel.AUTHENTICATED:
48+
return user.is_authorized
49+
case AppConfigAccessLevel.OWNER:
50+
match scope_type:
51+
case AppConfigScopeType.USER:
52+
return str(user.user_id) == scope_id
53+
case AppConfigScopeType.DOMAIN:
54+
# NOTE: assumes a domain fragment's scope_id holds the domain *name*
55+
# (matching UserData.domain_name). Not exercised by the default policy
56+
# (domain write defaults to ADMIN); verify when domain-owner writes land.
57+
return user.is_admin and user.domain_name == scope_id
58+
case AppConfigScopeType.PUBLIC:
59+
return False
60+
case AppConfigAccessLevel.ADMIN:
61+
# superadmin already returned True above; no one else satisfies ADMIN
62+
return False
63+
3064

3165
class AppConfigScopeType(enum.StrEnum):
3266
"""Scope at which an app config fragment is written (BEP-1052).

src/ai/backend/manager/api/adapters/app_config/adapter.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,16 @@ class AppConfigAdapter(BaseAdapter):
9999

100100
# --- admin fragment CRUD ---
101101

102-
async def admin_create(
103-
self, input: CreateAppConfigFragmentInput
104-
) -> CreateAppConfigFragmentPayload:
102+
async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload:
105103
spec = AppConfigFragmentCreatorSpec(
106104
config_name=input.config_name,
107105
scope_type=AppConfigScopeType(input.scope_type.value),
108106
scope_id=input.scope_id,
109107
config=input.config,
110108
)
109+
# Authorization is enforced in the service against the layer's write_access tier.
111110
action_result = await self._processors.app_config_fragment.create.wait_for_complete(
112-
CreateAppConfigFragmentAction(creator_spec=spec)
111+
CreateAppConfigFragmentAction(creator_spec=spec, requester=current_user())
113112
)
114113
return CreateAppConfigFragmentPayload(
115114
app_config_fragment=self._fragment_to_node(action_result.fragment),
@@ -121,30 +120,28 @@ async def admin_get(self, fragment_id: AppConfigFragmentID) -> AppConfigFragment
121120
)
122121
return self._fragment_to_node(action_result.fragment)
123122

124-
async def admin_update(
123+
async def update(
125124
self, fragment_id: AppConfigFragmentID, input: UpdateAppConfigFragmentInput
126125
) -> UpdateAppConfigFragmentPayload:
127126
updater = Updater(
128127
spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)),
129128
pk_value=fragment_id,
130129
)
131-
# No allow-list gate: a fragment row exists only while its entry does (FK with
132-
# cascade), so an existing fragment is always writable at its own scope.
130+
# Authorization is enforced in the service: it loads the fragment and checks the
131+
# caller against the layer's write_access tier before updating.
133132
action_result = await self._processors.app_config_fragment.update.wait_for_complete(
134-
UpdateAppConfigFragmentAction(updater=updater)
133+
UpdateAppConfigFragmentAction(updater=updater, requester=current_user())
135134
)
136135
return UpdateAppConfigFragmentPayload(
137136
app_config_fragment=self._fragment_to_node(action_result.fragment),
138137
)
139138

140-
async def admin_purge(
141-
self, input: PurgeAppConfigFragmentInput
142-
) -> PurgeAppConfigFragmentPayload:
139+
async def purge(self, input: PurgeAppConfigFragmentInput) -> PurgeAppConfigFragmentPayload:
143140
fragment_id = AppConfigFragmentID(input.id)
144141
purger = Purger(row_class=AppConfigFragmentRow, pk_value=fragment_id)
145-
# No allow-list gate — see ``admin_update``.
142+
# Authorization is enforced in the service — see ``update``.
146143
action_result = await self._processors.app_config_fragment.purge.wait_for_complete(
147-
PurgeAppConfigFragmentAction(purger=purger)
144+
PurgeAppConfigFragmentAction(purger=purger, requester=current_user())
148145
)
149146
return PurgeAppConfigFragmentPayload(id=action_result.fragment.id)
150147

src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ class V2AppConfigFragmentHandler:
3030
def __init__(self, *, adapter: AppConfigAdapter) -> None:
3131
self._adapter = adapter
3232

33-
async def admin_create(
33+
async def create(
3434
self,
3535
body: BodyParam[CreateAppConfigFragmentInput],
3636
) -> APIResponse:
37-
"""Create a fragment at any scope (superadmin only)."""
38-
result = await self._adapter.admin_create(body.parsed)
37+
"""Create a fragment; authorized by the layer's write_access tier (auth required)."""
38+
result = await self._adapter.create(body.parsed)
3939
return APIResponse.build(status_code=HTTPStatus.CREATED, response_model=result)
4040

4141
async def admin_get(
@@ -46,25 +46,23 @@ async def admin_get(
4646
result = await self._adapter.admin_get(AppConfigFragmentID(path.parsed.fragment_id))
4747
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
4848

49-
async def admin_update(
49+
async def update(
5050
self,
5151
path: PathParam[AppConfigFragmentIdPathParam],
5252
body: BodyParam[UpdateAppConfigFragmentInput],
5353
) -> APIResponse:
54-
"""Update a fragment's config document by id (superadmin only)."""
55-
result = await self._adapter.admin_update(
54+
"""Update a fragment's config; authorized by the layer's write_access tier (auth required)."""
55+
result = await self._adapter.update(
5656
AppConfigFragmentID(path.parsed.fragment_id), body.parsed
5757
)
5858
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
5959

60-
async def admin_purge(
60+
async def purge(
6161
self,
6262
path: PathParam[AppConfigFragmentIdPathParam],
6363
) -> APIResponse:
64-
"""Purge a fragment by id (superadmin only)."""
65-
result = await self._adapter.admin_purge(
66-
PurgeAppConfigFragmentInput(id=path.parsed.fragment_id)
67-
)
64+
"""Purge a fragment by id; authorized by the layer's write_access tier (auth required)."""
65+
result = await self._adapter.purge(PurgeAppConfigFragmentInput(id=path.parsed.fragment_id))
6866
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
6967

7068
async def admin_search(

src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,22 @@ def register_v2_app_config_fragment_routes(
2020
"""Register all REST v2 app config fragment routes.
2121
2222
Layout:
23-
POST / create a fragment (superadmin)
23+
POST / create a fragment (auth; write_access tier)
2424
POST /search admin paginated search (superadmin)
2525
POST /scoped-search principal-visible search (auth)
2626
GET /{fragment_id} get by id (superadmin)
27-
PATCH /{fragment_id} update config by id (superadmin)
28-
DELETE /{fragment_id} purge by id (superadmin)
27+
PATCH /{fragment_id} update config by id (auth; write_access tier)
28+
DELETE /{fragment_id} purge by id (auth; write_access tier)
2929
"""
3030
registry = RouteRegistry.create("app-config-fragments", route_deps.cors_options)
3131

32-
registry.add("POST", "/", handler.admin_create, middlewares=[superadmin_required])
32+
# Write routes are auth_required; the service authorizes each write against the target
33+
# layer's allow_list write_access tier (a superadmin passes any tier).
34+
registry.add("POST", "/", handler.create, middlewares=[auth_required])
3335
registry.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
3436
registry.add("POST", "/scoped-search", handler.scoped_search, middlewares=[auth_required])
3537
registry.add("GET", "/{fragment_id}", handler.admin_get, middlewares=[superadmin_required])
36-
registry.add("PATCH", "/{fragment_id}", handler.admin_update, middlewares=[superadmin_required])
37-
registry.add("DELETE", "/{fragment_id}", handler.admin_purge, middlewares=[superadmin_required])
38+
registry.add("PATCH", "/{fragment_id}", handler.update, middlewares=[auth_required])
39+
registry.add("DELETE", "/{fragment_id}", handler.purge, middlewares=[auth_required])
3840

3941
return registry

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import sqlalchemy as sa
1111

12+
from ai.backend.common.data.app_config.types import AppConfigScopeType
13+
from ai.backend.common.data.filter_specs import StringMatchSpec
1214
from ai.backend.common.exception import BackendAIError
1315
from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID
1416
from ai.backend.common.metrics.metric import DomainType, LayerType
@@ -20,10 +22,14 @@
2022
AppConfigAllowListSearchResult,
2123
)
2224
from ai.backend.manager.errors.app_config import AppConfigAllowListNotFound
25+
from ai.backend.manager.models.app_config_allow_list.conditions import (
26+
AppConfigAllowListConditions,
27+
)
2328
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
2429
from ai.backend.manager.repositories.base import (
2530
BatchQuerier,
2631
Creator,
32+
OffsetPagination,
2733
Purger,
2834
Querier,
2935
Updater,
@@ -94,6 +100,30 @@ async def purge(self, purger: Purger[AppConfigAllowListRow]) -> AppConfigAllowLi
94100
raise AppConfigAllowListNotFound("App config allow-list entry not found")
95101
return result.row.to_data()
96102

103+
@app_config_allow_list_db_source_resilience.apply()
104+
async def by_config_and_scope(
105+
self, config_name: str, scope_type: AppConfigScopeType
106+
) -> AppConfigAllowListData | None:
107+
"""The allow-list entry for the unique ``(config_name, scope_type)`` pair, or ``None``.
108+
109+
Used by write/read authorization to read the layer's access tiers.
110+
"""
111+
querier = BatchQuerier(
112+
pagination=OffsetPagination(limit=1, offset=0),
113+
conditions=[
114+
AppConfigAllowListConditions.by_config_name_equals(
115+
StringMatchSpec(config_name, case_insensitive=False, negated=False)
116+
),
117+
AppConfigAllowListConditions.by_scope_type_equals(scope_type),
118+
],
119+
)
120+
async with self._ops.read_ops() as r:
121+
result = await r.batch_query_in_global(sa.select(AppConfigAllowListRow), querier)
122+
if not result.rows:
123+
return None
124+
data: AppConfigAllowListData = result.rows[0].AppConfigAllowListRow.to_data()
125+
return data
126+
97127
@app_config_allow_list_db_source_resilience.apply()
98128
async def search(self, querier: BatchQuerier) -> AppConfigAllowListSearchResult:
99129
async with self._ops.read_ops() as r:

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from ai.backend.common.data.app_config.types import AppConfigScopeType
34
from ai.backend.common.exception import BackendAIError
45
from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID
56
from ai.backend.common.metrics.metric import DomainType, LayerType
@@ -63,6 +64,12 @@ async def create(
6364
async def get_by_id(self, allow_list_id: AppConfigAllowListID) -> AppConfigAllowListData:
6465
return await self._db_source.get_by_id(allow_list_id)
6566

67+
@app_config_allow_list_repository_resilience.apply()
68+
async def by_config_and_scope(
69+
self, config_name: str, scope_type: AppConfigScopeType
70+
) -> AppConfigAllowListData | None:
71+
return await self._db_source.by_config_and_scope(config_name, scope_type)
72+
6673
@app_config_allow_list_repository_resilience.apply()
6774
async def search(self, querier: BatchQuerier) -> AppConfigAllowListSearchResult:
6875
return await self._db_source.search(querier)

src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from typing import override
66

7+
from ai.backend.common.data.user.types import UserData
78
from ai.backend.manager.actions.types import ActionOperationType
89
from ai.backend.manager.repositories.app_config_fragment.creators import (
910
AppConfigFragmentCreatorSpec,
@@ -20,6 +21,7 @@ class BulkCreateAppConfigFragmentAction(AppConfigFragmentBulkAction):
2021
"""Create many fragments with per-item partial success; the FK to the allow-list gates each write."""
2122

2223
creator_specs: Sequence[AppConfigFragmentCreatorSpec]
24+
requester: UserData | None = None
2325

2426
@override
2527
@classmethod

src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from typing import cast, override
66

7+
from ai.backend.common.data.user.types import UserData
78
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
89
from ai.backend.manager.actions.types import ActionOperationType
910
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
@@ -20,6 +21,7 @@ class BulkPurgeAppConfigFragmentAction(AppConfigFragmentBulkAction):
2021
"""Purge many fragments with per-item partial success (no gate — purging the allow-list entry itself cascades to its fragments separately)."""
2122

2223
purgers: Sequence[Purger[AppConfigFragmentRow]]
24+
requester: UserData | None = None
2325

2426
@override
2527
@classmethod

src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from typing import cast, override
66

7+
from ai.backend.common.data.user.types import UserData
78
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
89
from ai.backend.manager.actions.types import ActionOperationType
910
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
@@ -20,6 +21,7 @@ class BulkUpdateAppConfigFragmentAction(AppConfigFragmentBulkAction):
2021
"""Update many fragments' ``config`` with per-item partial success (no gate — an existing fragment is always writable at its own scope)."""
2122

2223
updaters: Sequence[Updater[AppConfigFragmentRow]]
24+
requester: UserData | None = None
2325

2426
@override
2527
@classmethod

src/ai/backend/manager/services/app_config_fragment/actions/create.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import override
55

66
from ai.backend.common.data.permission.types import RBACElementType, ScopeType
7+
from ai.backend.common.data.user.types import UserData
78
from ai.backend.manager.actions.types import ActionOperationType
89
from ai.backend.manager.data.app_config_fragment.types import (
910
AppConfigFragmentData,
@@ -28,6 +29,7 @@ class CreateAppConfigFragmentAction(AppConfigFragmentScopeAction):
2829
"""
2930

3031
creator_spec: AppConfigFragmentCreatorSpec
32+
requester: UserData | None = None
3133

3234
@override
3335
@classmethod

0 commit comments

Comments
 (0)