Skip to content

Commit a7944ba

Browse files
jopemachineclaude
andcommitted
feat(BA-6860): RBAC-gate app_config fragment writes at the processors
Attach the RBAC validators to the app_config_fragment write processors (create -> scope, update/purge -> single-entity, bulk_* -> bulk) and pass validators through the factory. Reads (get/admin_search/scoped_search) carry no validator — they stay gated by the allow list and scope visibility. The fragment write routes move from superadmin_required to auth_required and the handler/adapter methods drop their admin_ prefix; RBAC resolves who may write (a user their own user-scope fragment via their system role and the fragment's scope association from BA-6859, a domain admin their domain's, a superadmin any scope — public stays superadmin-only, having no RBAC scope). Replaces the interim fixed-scope _may_write service check entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0c1ba3e commit a7944ba

6 files changed

Lines changed: 53 additions & 34 deletions

File tree

changes/12759.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Gate AppConfig fragment writes with RBAC: create/update/purge are validated by the RBAC processors (a user writes their own user-scope fragment, a domain admin their domain's, a superadmin any), while reads stay gated by the allow list and scope visibility; fragment write endpoints move from superadmin-only to authenticated (BEP-1052).

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,14 @@ 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+
# Write authorization is enforced by the RBAC validators on the processor.
111110
action_result = await self._processors.app_config_fragment.create.wait_for_complete(
112111
CreateAppConfigFragmentAction(creator_spec=spec)
113112
)
@@ -121,28 +120,26 @@ 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+
# Write authorization is enforced by the RBAC validators on the processor (the
131+
# fragment's scope is resolved via its RBAC scope association).
133132
action_result = await self._processors.app_config_fragment.update.wait_for_complete(
134133
UpdateAppConfigFragmentAction(updater=updater)
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+
# Write authorization is enforced by the RBAC validators — see ``update``.
146143
action_result = await self._processors.app_config_fragment.purge.wait_for_complete(
147144
PurgeAppConfigFragmentAction(purger=purger)
148145
)

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 permission policy (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 permission policy (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 permission policy (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: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,23 @@ 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; RBAC)
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; RBAC)
28+
DELETE /{fragment_id} purge by id (auth; RBAC)
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; write authorization is RBAC (validators on the
33+
# processors): a user writes their own user-scope fragment, a domain admin their
34+
# domain's, and only a superadmin the public scope. Reads stay allowlist/visibility.
35+
registry.add("POST", "/", handler.create, middlewares=[auth_required])
3336
registry.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
3437
registry.add("POST", "/scoped-search", handler.scoped_search, middlewares=[auth_required])
3538
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])
39+
registry.add("PATCH", "/{fragment_id}", handler.update, middlewares=[auth_required])
40+
registry.add("DELETE", "/{fragment_id}", handler.purge, middlewares=[auth_required])
3841

3942
return registry

src/ai/backend/manager/services/app_config_fragment/processors.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ai.backend.manager.actions.processor.scope import ScopeActionProcessor
88
from ai.backend.manager.actions.processor.single_entity import SingleEntityActionProcessor
99
from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec
10+
from ai.backend.manager.actions.validators import ActionValidators
1011
from ai.backend.manager.services.app_config_fragment.actions.admin_search import (
1112
AdminSearchAppConfigFragmentAction,
1213
AdminSearchAppConfigFragmentActionResult,
@@ -77,16 +78,35 @@ def __init__(
7778
self,
7879
service: AppConfigFragmentService,
7980
action_monitors: list[ActionMonitor],
81+
validators: ActionValidators,
8082
) -> None:
81-
self.create = ScopeActionProcessor(service.create, action_monitors)
83+
# Writes are RBAC-gated (a non-admin may write only their own user-scope fragment,
84+
# resolved via the caller's roles + the scope→entity association). Reads are gated by
85+
# the allow-list / visibility, not RBAC, so they carry no validator.
86+
scope_v = validators.rbac.scope
87+
single_v = validators.rbac.single_entity
88+
bulk_v = validators.rbac.bulk
89+
self.create = ScopeActionProcessor(
90+
service.create, monitors=action_monitors, validators=[scope_v]
91+
)
8292
self.get = SingleEntityActionProcessor(service.get, action_monitors)
8393
self.admin_search = ScopeActionProcessor(service.admin_search, action_monitors)
8494
self.scoped_search = BulkActionProcessor(service.scoped_search, monitors=action_monitors)
85-
self.update = SingleEntityActionProcessor(service.update, action_monitors)
86-
self.purge = SingleEntityActionProcessor(service.purge, action_monitors)
87-
self.bulk_create = BulkActionProcessor(service.bulk_create, monitors=action_monitors)
88-
self.bulk_update = BulkActionProcessor(service.bulk_update, monitors=action_monitors)
89-
self.bulk_purge = BulkActionProcessor(service.bulk_purge, monitors=action_monitors)
95+
self.update = SingleEntityActionProcessor(
96+
service.update, monitors=action_monitors, validators=[single_v]
97+
)
98+
self.purge = SingleEntityActionProcessor(
99+
service.purge, monitors=action_monitors, validators=[single_v]
100+
)
101+
self.bulk_create = BulkActionProcessor(
102+
service.bulk_create, monitors=action_monitors, validators=[bulk_v]
103+
)
104+
self.bulk_update = BulkActionProcessor(
105+
service.bulk_update, monitors=action_monitors, validators=[bulk_v]
106+
)
107+
self.bulk_purge = BulkActionProcessor(
108+
service.bulk_purge, monitors=action_monitors, validators=[bulk_v]
109+
)
90110

91111
@override
92112
def supported_actions(self) -> list[ActionSpec]:

src/ai/backend/manager/services/factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ def create_processors(
465465
services.app_config_allow_list, action_monitors
466466
),
467467
app_config_fragment=AppConfigFragmentProcessors(
468-
services.app_config_fragment, action_monitors
468+
services.app_config_fragment, action_monitors, validators
469469
),
470470
domain=DomainProcessors(services.domain, action_monitors, validators),
471471
dotfile=DotfileProcessors(services.dotfile, action_monitors, validators),

0 commit comments

Comments
 (0)