Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/12759.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +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).
12 changes: 6 additions & 6 deletions docs/manager/rest-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -39189,7 +39189,7 @@
},
"/v2/app-config-fragments/": {
"post": {
"operationId": "v2/app-config-fragments.admin_create",
"operationId": "v2/app-config-fragments.create",
"tags": [
"v2/app-config-fragments"
],
Expand All @@ -39213,7 +39213,7 @@
}
},
"parameters": [],
"description": "Create a fragment at any scope (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n"
"description": "Create a fragment; authorized by the layer's permission policy (auth required).\n\n**Preconditions:**\n* User privilege required.\n"
}
},
"/v2/app-config-fragments/search": {
Expand Down Expand Up @@ -39303,7 +39303,7 @@
"description": "Get a fragment by id (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n"
},
"patch": {
"operationId": "v2/app-config-fragments.admin_update",
"operationId": "v2/app-config-fragments.update",
"tags": [
"v2/app-config-fragments"
],
Expand Down Expand Up @@ -39336,10 +39336,10 @@
}
}
],
"description": "Update a fragment's config document by id (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n"
"description": "Update a fragment's config; authorized by the layer's permission policy (auth required).\n\n**Preconditions:**\n* User privilege required.\n"
},
"delete": {
"operationId": "v2/app-config-fragments.admin_purge",
"operationId": "v2/app-config-fragments.purge",
"tags": [
"v2/app-config-fragments"
],
Expand All @@ -39363,7 +39363,7 @@
}
}
],
"description": "Purge a fragment by id (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n"
"description": "Purge a fragment by id; authorized by the layer's permission policy (auth required).\n\n**Preconditions:**\n* User privilege required.\n"
}
},
"/v2/app-config-allow-list/": {
Expand Down
17 changes: 7 additions & 10 deletions src/ai/backend/manager/api/adapters/app_config/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,14 @@ class AppConfigAdapter(BaseAdapter):

# --- admin fragment CRUD ---

async def admin_create(
self, input: CreateAppConfigFragmentInput
) -> CreateAppConfigFragmentPayload:
async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload:
spec = AppConfigFragmentCreatorSpec(
config_name=input.config_name,
scope_type=AppConfigScopeType(input.scope_type.value),
scope_id=input.scope_id,
config=input.config,
)
# Write authorization is enforced by the RBAC validators on the processor.
action_result = await self._processors.app_config_fragment.create.wait_for_complete(
CreateAppConfigFragmentAction(creator_spec=spec)
)
Expand All @@ -121,28 +120,26 @@ async def admin_get(self, fragment_id: AppConfigFragmentID) -> AppConfigFragment
)
return self._fragment_to_node(action_result.fragment)

async def admin_update(
async def update(
self, fragment_id: AppConfigFragmentID, input: UpdateAppConfigFragmentInput
) -> UpdateAppConfigFragmentPayload:
updater = Updater(
spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)),
pk_value=fragment_id,
)
# No allow-list gate: a fragment row exists only while its entry does (FK with
# cascade), so an existing fragment is always writable at its own scope.
# Write authorization is enforced by the RBAC validators on the processor (the
# fragment's scope is resolved via its RBAC scope association).
action_result = await self._processors.app_config_fragment.update.wait_for_complete(
UpdateAppConfigFragmentAction(updater=updater)
)
return UpdateAppConfigFragmentPayload(
app_config_fragment=self._fragment_to_node(action_result.fragment),
)

async def admin_purge(
self, input: PurgeAppConfigFragmentInput
) -> PurgeAppConfigFragmentPayload:
async def purge(self, input: PurgeAppConfigFragmentInput) -> PurgeAppConfigFragmentPayload:
fragment_id = AppConfigFragmentID(input.id)
purger = Purger(row_class=AppConfigFragmentRow, pk_value=fragment_id)
# No allow-list gate — see ``admin_update``.
# Write authorization is enforced by the RBAC validators — see ``update``.
action_result = await self._processors.app_config_fragment.purge.wait_for_complete(
PurgeAppConfigFragmentAction(purger=purger)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ class V2AppConfigFragmentHandler:
def __init__(self, *, adapter: AppConfigAdapter) -> None:
self._adapter = adapter

async def admin_create(
async def create(
self,
body: BodyParam[CreateAppConfigFragmentInput],
) -> APIResponse:
"""Create a fragment at any scope (superadmin only)."""
result = await self._adapter.admin_create(body.parsed)
"""Create a fragment; authorized by the layer's permission policy (auth required)."""
result = await self._adapter.create(body.parsed)
return APIResponse.build(status_code=HTTPStatus.CREATED, response_model=result)

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

async def admin_update(
async def update(
self,
path: PathParam[AppConfigFragmentIdPathParam],
body: BodyParam[UpdateAppConfigFragmentInput],
) -> APIResponse:
"""Update a fragment's config document by id (superadmin only)."""
result = await self._adapter.admin_update(
"""Update a fragment's config; authorized by the layer's permission policy (auth required)."""
result = await self._adapter.update(
AppConfigFragmentID(path.parsed.fragment_id), body.parsed
)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def admin_purge(
async def purge(
self,
path: PathParam[AppConfigFragmentIdPathParam],
) -> APIResponse:
"""Purge a fragment by id (superadmin only)."""
result = await self._adapter.admin_purge(
PurgeAppConfigFragmentInput(id=path.parsed.fragment_id)
)
"""Purge a fragment by id; authorized by the layer's permission policy (auth required)."""
result = await self._adapter.purge(PurgeAppConfigFragmentInput(id=path.parsed.fragment_id))
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def admin_search(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,23 @@ def register_v2_app_config_fragment_routes(
"""Register all REST v2 app config fragment routes.

Layout:
POST / create a fragment (superadmin)
POST / create a fragment (auth; RBAC)
POST /search admin paginated search (superadmin)
POST /scoped-search principal-visible search (auth)
GET /{fragment_id} get by id (superadmin)
PATCH /{fragment_id} update config by id (superadmin)
DELETE /{fragment_id} purge by id (superadmin)
PATCH /{fragment_id} update config by id (auth; RBAC)
DELETE /{fragment_id} purge by id (auth; RBAC)
"""
registry = RouteRegistry.create("app-config-fragments", route_deps.cors_options)

registry.add("POST", "/", handler.admin_create, middlewares=[superadmin_required])
# Write routes are auth_required; write authorization is RBAC (validators on the
# processors): a user writes their own user-scope fragment, a domain admin their
# domain's, and only a superadmin the public scope. Reads stay allowlist/visibility.
registry.add("POST", "/", handler.create, middlewares=[auth_required])
registry.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
registry.add("POST", "/scoped-search", handler.scoped_search, middlewares=[auth_required])
registry.add("GET", "/{fragment_id}", handler.admin_get, middlewares=[superadmin_required])
registry.add("PATCH", "/{fragment_id}", handler.admin_update, middlewares=[superadmin_required])
registry.add("DELETE", "/{fragment_id}", handler.admin_purge, middlewares=[superadmin_required])
registry.add("PATCH", "/{fragment_id}", handler.update, middlewares=[auth_required])
registry.add("DELETE", "/{fragment_id}", handler.purge, middlewares=[auth_required])

return registry
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ai.backend.manager.actions.processor.scope import ScopeActionProcessor
from ai.backend.manager.actions.processor.single_entity import SingleEntityActionProcessor
from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec
from ai.backend.manager.actions.validators import ActionValidators
from ai.backend.manager.services.app_config_fragment.actions.admin_search import (
AdminSearchAppConfigFragmentAction,
AdminSearchAppConfigFragmentActionResult,
Expand Down Expand Up @@ -77,16 +78,35 @@ def __init__(
self,
service: AppConfigFragmentService,
action_monitors: list[ActionMonitor],
validators: ActionValidators,
) -> None:
self.create = ScopeActionProcessor(service.create, action_monitors)
# Writes are RBAC-gated (a non-admin may write only their own user-scope fragment,
# resolved via the caller's roles + the scope→entity association). Reads are gated by
# the allow-list / visibility, not RBAC, so they carry no validator.
scope_v = validators.rbac.scope
single_v = validators.rbac.single_entity
bulk_v = validators.rbac.bulk
self.create = ScopeActionProcessor(
service.create, monitors=action_monitors, validators=[scope_v]
)
self.get = SingleEntityActionProcessor(service.get, action_monitors)
self.admin_search = ScopeActionProcessor(service.admin_search, action_monitors)
self.scoped_search = BulkActionProcessor(service.scoped_search, monitors=action_monitors)
self.update = SingleEntityActionProcessor(service.update, action_monitors)
self.purge = SingleEntityActionProcessor(service.purge, action_monitors)
self.bulk_create = BulkActionProcessor(service.bulk_create, monitors=action_monitors)
self.bulk_update = BulkActionProcessor(service.bulk_update, monitors=action_monitors)
self.bulk_purge = BulkActionProcessor(service.bulk_purge, monitors=action_monitors)
self.update = SingleEntityActionProcessor(
service.update, monitors=action_monitors, validators=[single_v]
)
self.purge = SingleEntityActionProcessor(
service.purge, monitors=action_monitors, validators=[single_v]
)
self.bulk_create = BulkActionProcessor(
service.bulk_create, monitors=action_monitors, validators=[bulk_v]
)
self.bulk_update = BulkActionProcessor(
service.bulk_update, monitors=action_monitors, validators=[bulk_v]
)
self.bulk_purge = BulkActionProcessor(
service.bulk_purge, monitors=action_monitors, validators=[bulk_v]
)

@override
def supported_actions(self) -> list[ActionSpec]:
Expand Down
2 changes: 1 addition & 1 deletion src/ai/backend/manager/services/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def create_processors(
services.app_config_allow_list, action_monitors
),
app_config_fragment=AppConfigFragmentProcessors(
services.app_config_fragment, action_monitors
services.app_config_fragment, action_monitors, validators
),
domain=DomainProcessors(services.domain, action_monitors, validators),
dotfile=DotfileProcessors(services.dotfile, action_monitors, validators),
Expand Down
Loading