From 70cb92a098ad67f98345ea6f8ee4ff843c72b06c Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 20 Mar 2026 02:01:10 +0900 Subject: [PATCH 1/6] feat(BA-4525): Unify ScopeType/EntityType to RBACElementType in permission controller Replace ScopeType and EntityType with RBACElementType at interface boundaries in the permission controller repository, service, and their direct GQL/REST callers. Legacy types are bridged at DB row construction and ScopeId creation. Repository layer: - creators.py, updaters.py, options.py: Accept RBACElementType, bridge internally - repository.py: search_scopes() takes RBACElementType, GLOBAL moved to handler - db_source.py, role_manager.py: Bridge at PermissionCreatorSpec call sites - granter.py, revoker.py: Accept RBACElementType for scope type fields Service layer: - get_scope_types/get_entity_types: Return list[RBACElementType] - search_scopes: Accept RBACElementType instead of ScopeType GQL/REST layers: - Simplify double-conversion (.to_element().to_scope_type()) to .to_element() - Add GLOBAL scope early-return in REST handler Co-Authored-By: Claude Opus 4.6 --- .../manager/api/rest/rbac/entity_adapter.py | 10 ++--- .../backend/manager/api/rest/rbac/handler.py | 45 ++++++++++++++++--- .../rest/rbac/object_permission_adapter.py | 2 +- .../api/rest/rbac/permission_adapter.py | 4 +- .../manager/models/rbac_models/conditions.py | 25 ++++++----- .../permission_controller/creators.py | 15 +++---- .../db_source/db_source.py | 7 +-- .../permission_controller/repository.py | 20 ++++----- .../permission_controller/role_manager.py | 2 +- .../permission_controller/updaters.py | 11 +++-- .../actions/get_entity_types.py | 4 +- .../actions/get_scope_types.py | 4 +- .../actions/search_scopes.py | 4 +- .../services/permission_contoller/service.py | 14 +++--- 14 files changed, 98 insertions(+), 69 deletions(-) diff --git a/src/ai/backend/manager/api/rest/rbac/entity_adapter.py b/src/ai/backend/manager/api/rest/rbac/entity_adapter.py index bac3afd2134..cf67dcef953 100644 --- a/src/ai/backend/manager/api/rest/rbac/entity_adapter.py +++ b/src/ai/backend/manager/api/rest/rbac/entity_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations -from ai.backend.common.data.permission.types import EntityType, ScopeType +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.common.dto.manager.rbac.request import ( SearchEntitiesRequest, ) @@ -26,17 +26,17 @@ class EntityAdapter(BaseFilterAdapter): def build_querier( self, - scope_type: ScopeType, + scope_type: RBACElementType, scope_id: str, - entity_type: EntityType, + entity_type: RBACElementType, request: SearchEntitiesRequest, ) -> BatchQuerier: """Build a BatchQuerier for entity search. Args: - scope_type: The scope type to search within + scope_type: The RBAC element type of scope to search within scope_id: The scope ID to search within - entity_type: The type of entity to search + entity_type: The RBAC element type of entity to search request: The search request containing pagination info Returns: diff --git a/src/ai/backend/manager/api/rest/rbac/handler.py b/src/ai/backend/manager/api/rest/rbac/handler.py index 476b8dcf689..74fad80b9c9 100644 --- a/src/ai/backend/manager/api/rest/rbac/handler.py +++ b/src/ai/backend/manager/api/rest/rbac/handler.py @@ -11,6 +11,11 @@ from http import HTTPStatus from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam +from ai.backend.common.data.permission.types import ( + GLOBAL_SCOPE_ID, + EntityType, + ScopeType, +) from ai.backend.common.dto.manager.rbac import ( AssignRoleRequest, AssignRoleResponse, @@ -44,7 +49,9 @@ SearchEntitiesResponse, SearchScopesResponse, ) +from ai.backend.manager.data.permission.id import ScopeId from ai.backend.manager.data.permission.role import UserRoleAssignmentInput, UserRoleRevocationInput +from ai.backend.manager.data.permission.types import ScopeData from ai.backend.manager.dto.context import UserContext from ai.backend.manager.errors.permission import NotEnoughPermission from ai.backend.manager.models.rbac_models.role import RoleRow @@ -298,7 +305,13 @@ async def get_scope_types( action_result = await self._permission_controller.get_scope_types.wait_for_complete( GetScopeTypesAction() ) - resp = GetScopeTypesResponse(items=action_result.scope_types) + scope_types: list[ScopeType] = [] + for et in action_result.element_types: + try: + scope_types.append(et.to_scope_type()) + except Exception: + pass + resp = GetScopeTypesResponse(items=scope_types) return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp) async def search_scopes( @@ -312,8 +325,24 @@ async def search_scopes( raise NotEnoughPermission("Only superadmin can search scopes.") scope_type = path.parsed.scope_type + # Handle GLOBAL scope as a static early-return (GLOBAL is not in RBACElementType) + if scope_type.value == GLOBAL_SCOPE_ID: + global_result = SearchScopesResponse( + items=[ + self._scope_adapter.convert_to_dto( + ScopeData( + id=ScopeId(scope_type=ScopeType.GLOBAL, scope_id=GLOBAL_SCOPE_ID), + name=GLOBAL_SCOPE_ID, + ) + ) + ], + pagination=PaginationInfo(total=1, offset=0, limit=1), + ) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=global_result) + + element_type = scope_type.to_element() querier = self._scope_adapter.build_querier(scope_type, body.parsed) - action = SearchScopesAction(scope_type=scope_type, querier=querier) + action = SearchScopesAction(element_type=element_type, querier=querier) action_result = await self._permission_controller.search_scopes.wait_for_complete(action) resp = SearchScopesResponse( items=[self._scope_adapter.convert_to_dto(item) for item in action_result.result.items], @@ -338,7 +367,13 @@ async def get_entity_types( action_result = await self._permission_controller.get_entity_types.wait_for_complete( GetEntityTypesAction() ) - resp = GetEntityTypesResponse(items=action_result.entity_types) + entity_types: list[EntityType] = [] + for et in action_result.element_types: + try: + entity_types.append(et.to_entity_type()) + except Exception: + pass + resp = GetEntityTypesResponse(items=entity_types) return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp) async def search_entities( @@ -352,9 +387,9 @@ async def search_entities( raise NotEnoughPermission("Only superadmin can search entities.") querier = self._entity_adapter.build_querier( - scope_type=path.parsed.scope_type, + scope_type=path.parsed.scope_type.to_element(), scope_id=path.parsed.scope_id, - entity_type=path.parsed.entity_type, + entity_type=path.parsed.entity_type.to_element(), request=body.parsed, ) action = SearchEntitiesAction(querier=querier) diff --git a/src/ai/backend/manager/api/rest/rbac/object_permission_adapter.py b/src/ai/backend/manager/api/rest/rbac/object_permission_adapter.py index ea2aa61571a..a36c478a0e3 100644 --- a/src/ai/backend/manager/api/rest/rbac/object_permission_adapter.py +++ b/src/ai/backend/manager/api/rest/rbac/object_permission_adapter.py @@ -46,7 +46,7 @@ def to_create_object_permission_action( creator = Creator( spec=ObjectPermissionCreatorSpec( role_id=request.role_id, - entity_type=request.entity_type, + entity_type=request.entity_type.to_element(), entity_id=request.entity_id, operation=request.operation, status=request.status, diff --git a/src/ai/backend/manager/api/rest/rbac/permission_adapter.py b/src/ai/backend/manager/api/rest/rbac/permission_adapter.py index d2856a67cb4..e7c05fbd511 100644 --- a/src/ai/backend/manager/api/rest/rbac/permission_adapter.py +++ b/src/ai/backend/manager/api/rest/rbac/permission_adapter.py @@ -43,9 +43,9 @@ def to_create_permission_action(request: CreatePermissionRequest) -> CreatePermi creator = Creator( spec=PermissionCreatorSpec( role_id=request.role_id, - scope_type=request.scope_type, + scope_type=request.scope_type.to_element(), scope_id=request.scope_id, - entity_type=request.entity_type, + entity_type=request.entity_type.to_element(), operation=request.operation, ) ) diff --git a/src/ai/backend/manager/models/rbac_models/conditions.py b/src/ai/backend/manager/models/rbac_models/conditions.py index 0cffb545904..a3054293d05 100644 --- a/src/ai/backend/manager/models/rbac_models/conditions.py +++ b/src/ai/backend/manager/models/rbac_models/conditions.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from ai.backend.common.data.filter_specs import StringMatchSpec +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.manager.data.permission.id import ObjectId from ai.backend.manager.data.permission.status import RoleStatus from ai.backend.manager.data.permission.types import ( @@ -175,14 +176,14 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner @staticmethod - def by_has_permission_for(entity_type: EntityType) -> QueryCondition: + def by_has_permission_for(element_type: RBACElementType) -> QueryCondition: """Filter roles having permission for entity type. Requires JOIN with ObjectPermissionRow. """ def inner() -> sa.sql.expression.ColumnElement[bool]: - return ObjectPermissionRow.entity_type == entity_type + return ObjectPermissionRow.entity_type == element_type.to_entity_type() return inner @@ -641,9 +642,9 @@ class EntityScopeConditions: """Query conditions for entity scope search.""" @staticmethod - def by_scope_type(scope_type: ScopeType) -> QueryCondition: + def by_scope_type(element_type: RBACElementType) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: - return AssociationScopesEntitiesRow.scope_type == scope_type + return AssociationScopesEntitiesRow.scope_type == element_type.to_scope_type() return inner @@ -655,9 +656,9 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner @staticmethod - def by_entity_type(entity_type: EntityType) -> QueryCondition: + def by_entity_type(element_type: RBACElementType) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: - return AssociationScopesEntitiesRow.entity_type == entity_type + return AssociationScopesEntitiesRow.entity_type == element_type.to_entity_type() return inner @@ -791,9 +792,9 @@ class ScopedPermissionConditions: """Query conditions for scoped permissions.""" @staticmethod - def by_entity_type(entity_type: EntityType) -> QueryCondition: + def by_entity_type(element_type: RBACElementType) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: - return PermissionRow.entity_type == entity_type + return PermissionRow.entity_type == element_type.to_entity_type() return inner @@ -832,9 +833,9 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner @staticmethod - def by_scope_type(scope_type: ScopeType) -> QueryCondition: + def by_scope_type(element_type: RBACElementType) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: - return PermissionRow.scope_type == scope_type + return PermissionRow.scope_type == element_type.to_scope_type() return inner @@ -864,9 +865,9 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner @staticmethod - def by_entity_type(entity_type: EntityType) -> QueryCondition: + def by_entity_type(element_type: RBACElementType) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: - return ObjectPermissionRow.entity_type == entity_type + return ObjectPermissionRow.entity_type == element_type.to_entity_type() return inner diff --git a/src/ai/backend/manager/repositories/permission_controller/creators.py b/src/ai/backend/manager/repositories/permission_controller/creators.py index d1b06038de8..672283c620a 100644 --- a/src/ai/backend/manager/repositories/permission_controller/creators.py +++ b/src/ai/backend/manager/repositories/permission_controller/creators.py @@ -7,13 +7,12 @@ from dataclasses import dataclass from typing import override +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.manager.data.permission.id import ObjectId, ScopeId from ai.backend.manager.data.permission.status import PermissionStatus, RoleStatus from ai.backend.manager.data.permission.types import ( - EntityType, OperationType, RoleSource, - ScopeType, ) from ai.backend.manager.errors.permission import RoleAlreadyAssigned from ai.backend.manager.errors.repository import UniqueConstraintViolationError @@ -56,18 +55,18 @@ class PermissionCreatorSpec(CreatorSpec[PermissionRow]): """CreatorSpec for permissions.""" role_id: uuid.UUID - scope_type: ScopeType + scope_type: RBACElementType scope_id: str - entity_type: EntityType + entity_type: RBACElementType operation: OperationType @override def build_row(self) -> PermissionRow: return PermissionRow( role_id=self.role_id, - scope_type=self.scope_type, + scope_type=self.scope_type.to_scope_type(), scope_id=self.scope_id, - entity_type=self.entity_type, + entity_type=self.entity_type.to_entity_type(), operation=self.operation, ) @@ -77,7 +76,7 @@ class ObjectPermissionCreatorSpec(CreatorSpec[ObjectPermissionRow]): """CreatorSpec for object permissions.""" role_id: uuid.UUID - entity_type: EntityType + entity_type: RBACElementType entity_id: str operation: OperationType status: PermissionStatus = PermissionStatus.ACTIVE @@ -86,7 +85,7 @@ class ObjectPermissionCreatorSpec(CreatorSpec[ObjectPermissionRow]): def build_row(self) -> ObjectPermissionRow: return ObjectPermissionRow( role_id=self.role_id, - entity_type=self.entity_type, + entity_type=self.entity_type.to_entity_type(), entity_id=self.entity_id, operation=self.operation, ) diff --git a/src/ai/backend/manager/repositories/permission_controller/db_source/db_source.py b/src/ai/backend/manager/repositories/permission_controller/db_source/db_source.py index 336fde9cbdd..533af3324ea 100644 --- a/src/ai/backend/manager/repositories/permission_controller/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/permission_controller/db_source/db_source.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import contains_eager, selectinload from ai.backend.common.data.permission.types import ( + RBACElementType, RelationType, ) from ai.backend.logging.utils import BraceStyleAdapter @@ -338,9 +339,9 @@ async def update_role_permissions( perm_creator = Creator( spec=PermissionCreatorSpec( role_id=input_data.role_id, - scope_type=scoped_perm_input.scope_type, + scope_type=RBACElementType(scoped_perm_input.scope_type.value), scope_id=scoped_perm_input.scope_id, - entity_type=scoped_perm_input.entity_type, + entity_type=RBACElementType(scoped_perm_input.entity_type.value), operation=scoped_perm_input.operation, ) ) @@ -356,7 +357,7 @@ async def update_role_permissions( obj_perm_creator = Creator( spec=ObjectPermissionCreatorSpec( role_id=input_data.role_id, - entity_type=obj_perm_input.entity_type, + entity_type=RBACElementType(obj_perm_input.entity_type.value), entity_id=obj_perm_input.entity_id, operation=obj_perm_input.operation, status=obj_perm_input.status, diff --git a/src/ai/backend/manager/repositories/permission_controller/repository.py b/src/ai/backend/manager/repositories/permission_controller/repository.py index ef72d5d36f2..b9ef2d70a54 100644 --- a/src/ai/backend/manager/repositories/permission_controller/repository.py +++ b/src/ai/backend/manager/repositories/permission_controller/repository.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from typing import cast -from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, OperationType +from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, OperationType, RBACElementType from ai.backend.common.exception import BackendAIError from ai.backend.common.metrics.metric import DomainType, LayerType from ai.backend.common.resilience.policies.metrics import MetricArgs, MetricPolicy @@ -303,7 +303,7 @@ async def search_users_assigned_to_role( querier=querier, ) - def _get_global_scope(self) -> ScopeListResult: + def get_global_scope(self) -> ScopeListResult: """Get the global scope as a static result.""" return ScopeListResult( items=[ @@ -320,26 +320,24 @@ def _get_global_scope(self) -> ScopeListResult: @permission_controller_repository_resilience.apply() async def search_scopes( self, - scope_type: ScopeType, + element_type: RBACElementType, querier: BatchQuerier, ) -> ScopeListResult: - """Search scopes based on scope type. + """Search scopes based on element type. Args: - scope_type: The type of scope to search. + element_type: The RBAC element type of scope to search. querier: BatchQuerier with conditions, orders, and pagination. Returns: ScopeListResult with matching scopes. """ - match scope_type: - case ScopeType.GLOBAL: - return self._get_global_scope() - case ScopeType.DOMAIN: + match element_type: + case RBACElementType.DOMAIN: return await self._db_source.search_domain_scopes(querier) - case ScopeType.PROJECT: + case RBACElementType.PROJECT: return await self._db_source.search_project_scopes(querier) - case ScopeType.USER: + case RBACElementType.USER: return await self._db_source.search_user_scopes(querier) case _: raise NotImplementedError( diff --git a/src/ai/backend/manager/repositories/permission_controller/role_manager.py b/src/ai/backend/manager/repositories/permission_controller/role_manager.py index bb77f1a91ae..01b6ae6af8e 100644 --- a/src/ai/backend/manager/repositories/permission_controller/role_manager.py +++ b/src/ai/backend/manager/repositories/permission_controller/role_manager.py @@ -147,7 +147,7 @@ async def add_object_permission_to_user_role( Creator( spec=ObjectPermissionCreatorSpec( role_id=role_id, - entity_type=entity_id.entity_type, + entity_type=entity_id.entity_type.to_element(), entity_id=entity_id.entity_id, operation=operation, ) diff --git a/src/ai/backend/manager/repositories/permission_controller/updaters.py b/src/ai/backend/manager/repositories/permission_controller/updaters.py index 10879a14a33..9e7e186b4ea 100644 --- a/src/ai/backend/manager/repositories/permission_controller/updaters.py +++ b/src/ai/backend/manager/repositories/permission_controller/updaters.py @@ -3,12 +3,11 @@ from dataclasses import dataclass, field from typing import Any, override +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.manager.data.permission.status import RoleStatus from ai.backend.manager.data.permission.types import ( - EntityType, OperationType, RoleSource, - ScopeType, ) from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow from ai.backend.manager.models.rbac_models.role import RoleRow @@ -44,9 +43,9 @@ def build_values(self) -> dict[str, Any]: class PermissionUpdaterSpec(UpdaterSpec[PermissionRow]): """UpdaterSpec for permission updates.""" - scope_type: OptionalState[ScopeType] = field(default_factory=OptionalState.nop) + scope_type: OptionalState[RBACElementType] = field(default_factory=OptionalState.nop) scope_id: OptionalState[str] = field(default_factory=OptionalState.nop) - entity_type: OptionalState[EntityType] = field(default_factory=OptionalState.nop) + entity_type: OptionalState[RBACElementType] = field(default_factory=OptionalState.nop) operation: OptionalState[OperationType] = field(default_factory=OptionalState.nop) @property @@ -57,8 +56,8 @@ def row_class(self) -> type[PermissionRow]: @override def build_values(self) -> dict[str, Any]: to_update: dict[str, Any] = {} - self.scope_type.update_dict(to_update, "scope_type") + self.scope_type.map(lambda v: v.to_scope_type()).update_dict(to_update, "scope_type") self.scope_id.update_dict(to_update, "scope_id") - self.entity_type.update_dict(to_update, "entity_type") + self.entity_type.map(lambda v: v.to_entity_type()).update_dict(to_update, "entity_type") self.operation.update_dict(to_update, "operation") return to_update diff --git a/src/ai/backend/manager/services/permission_contoller/actions/get_entity_types.py b/src/ai/backend/manager/services/permission_contoller/actions/get_entity_types.py index 74b7c73fc95..fc1641c741e 100644 --- a/src/ai/backend/manager/services/permission_contoller/actions/get_entity_types.py +++ b/src/ai/backend/manager/services/permission_contoller/actions/get_entity_types.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import override -from ai.backend.common.data.permission.types import EntityType +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.manager.actions.action import BaseActionResult from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.services.permission_contoller.actions.base import RoleAction @@ -31,7 +31,7 @@ def operation_type(cls) -> ActionOperationType: class GetEntityTypesActionResult(BaseActionResult): """Result of getting entity types.""" - entity_types: list[EntityType] + element_types: list[RBACElementType] @override def entity_id(self) -> str | None: diff --git a/src/ai/backend/manager/services/permission_contoller/actions/get_scope_types.py b/src/ai/backend/manager/services/permission_contoller/actions/get_scope_types.py index 347e41c42f5..007cf1d6e2c 100644 --- a/src/ai/backend/manager/services/permission_contoller/actions/get_scope_types.py +++ b/src/ai/backend/manager/services/permission_contoller/actions/get_scope_types.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import override -from ai.backend.common.data.permission.types import ScopeType +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.manager.actions.action import BaseActionResult from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.services.permission_contoller.actions.base import RoleAction @@ -31,7 +31,7 @@ def operation_type(cls) -> ActionOperationType: class GetScopeTypesActionResult(BaseActionResult): """Result of getting scope types.""" - scope_types: list[ScopeType] + element_types: list[RBACElementType] @override def entity_id(self) -> str | None: diff --git a/src/ai/backend/manager/services/permission_contoller/actions/search_scopes.py b/src/ai/backend/manager/services/permission_contoller/actions/search_scopes.py index 1d6ec09b2eb..6af91473a38 100644 --- a/src/ai/backend/manager/services/permission_contoller/actions/search_scopes.py +++ b/src/ai/backend/manager/services/permission_contoller/actions/search_scopes.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import override -from ai.backend.common.data.permission.types import ScopeType +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.manager.actions.action import SearchActionResult from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.data.permission.types import ScopeData @@ -19,7 +19,7 @@ class SearchScopesAction(RoleAction): Permission check is performed at the API handler level. """ - scope_type: ScopeType + element_type: RBACElementType querier: BatchQuerier @override diff --git a/src/ai/backend/manager/services/permission_contoller/service.py b/src/ai/backend/manager/services/permission_contoller/service.py index a3f4292dd6c..8238b1f61ff 100644 --- a/src/ai/backend/manager/services/permission_contoller/service.py +++ b/src/ai/backend/manager/services/permission_contoller/service.py @@ -1,11 +1,7 @@ import logging from collections.abc import Sequence -from ai.backend.common.data.permission.types import ( - EntityType, - RBACElementType, - ScopeType, -) +from ai.backend.common.data.permission.types import RBACElementType from ai.backend.logging.utils import BraceStyleAdapter from ai.backend.manager.actions.action.rbac import ( BaseRBACAction, @@ -276,17 +272,17 @@ async def update_role_permissions( return UpdateRolePermissionsActionResult(role=result) async def search_scopes(self, action: SearchScopesAction) -> SearchScopesActionResult: - """Search scopes based on scope type.""" - result = await self._repository.search_scopes(action.scope_type, action.querier) + """Search scopes based on element type.""" + result = await self._repository.search_scopes(action.element_type, action.querier) return SearchScopesActionResult(result=result) async def get_scope_types(self, _action: GetScopeTypesAction) -> GetScopeTypesActionResult: """Get all available scope types.""" - return GetScopeTypesActionResult(scope_types=list(ScopeType)) + return GetScopeTypesActionResult(element_types=list(RBACElementType)) async def get_entity_types(self, _action: GetEntityTypesAction) -> GetEntityTypesActionResult: """Get all available entity types.""" - return GetEntityTypesActionResult(entity_types=list(EntityType)) + return GetEntityTypesActionResult(element_types=list(RBACElementType)) async def search_entities(self, action: SearchEntitiesAction) -> SearchEntitiesActionResult: """Search entities within a scope.""" From 1d8491523a80fee6fb719ad5f5d75c7ca19a61bd Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 20 Mar 2026 02:01:51 +0900 Subject: [PATCH 2/6] changelog: add news fragment for PR #10335 --- changes/10335.enhance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/10335.enhance.md diff --git a/changes/10335.enhance.md b/changes/10335.enhance.md new file mode 100644 index 00000000000..9a5478540e8 --- /dev/null +++ b/changes/10335.enhance.md @@ -0,0 +1 @@ +Unify ScopeType/EntityType to RBACElementType in permission controller repository, service, and their direct GQL/REST callers From 20bc3714f97d8276bb1ad5ced426c36929362269 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Tue, 24 Mar 2026 02:33:08 +0900 Subject: [PATCH 3/6] fix(BA-4525): Narrow exception catch to RBACTypeConversionError in handler Co-Authored-By: Claude Opus 4.6 --- src/ai/backend/manager/api/rest/rbac/handler.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ai/backend/manager/api/rest/rbac/handler.py b/src/ai/backend/manager/api/rest/rbac/handler.py index 74fad80b9c9..3494a6bffd4 100644 --- a/src/ai/backend/manager/api/rest/rbac/handler.py +++ b/src/ai/backend/manager/api/rest/rbac/handler.py @@ -49,6 +49,7 @@ SearchEntitiesResponse, SearchScopesResponse, ) +from ai.backend.common.exception import RBACTypeConversionError from ai.backend.manager.data.permission.id import ScopeId from ai.backend.manager.data.permission.role import UserRoleAssignmentInput, UserRoleRevocationInput from ai.backend.manager.data.permission.types import ScopeData @@ -309,7 +310,7 @@ async def get_scope_types( for et in action_result.element_types: try: scope_types.append(et.to_scope_type()) - except Exception: + except RBACTypeConversionError: pass resp = GetScopeTypesResponse(items=scope_types) return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp) @@ -371,7 +372,7 @@ async def get_entity_types( for et in action_result.element_types: try: entity_types.append(et.to_entity_type()) - except Exception: + except RBACTypeConversionError: pass resp = GetEntityTypesResponse(items=entity_types) return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp) From f5d50d5d2205ab90112e2fece8410663cf979350 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Tue, 24 Mar 2026 02:52:00 +0900 Subject: [PATCH 4/6] fix(BA-4525): Update callers of changed interfaces for type consistency Replace ScopeType/EntityType with RBACElementType at all call sites where permission controller interfaces now expect the unified enum. Update test files to use RBACElementType for scope and entity type parameters, and remove unused GLOBAL scope references from tests. Co-Authored-By: Claude Opus 4.6 --- src/ai/backend/manager/api/adapters/rbac.py | 21 ++-- tests/component/rbac/test_rbac_permission.py | 99 +++++++++++-------- .../manager/api/rbac/test_scope_handlers.py | 11 ++- .../test_search_element_associations.py | 4 +- .../test_search_permissions.py | 5 +- .../test_search_scopes.py | 66 ++++++++----- .../test_permission_controller_service.py | 7 +- .../test_search_scopes_service.py | 49 ++------- 8 files changed, 134 insertions(+), 128 deletions(-) diff --git a/src/ai/backend/manager/api/adapters/rbac.py b/src/ai/backend/manager/api/adapters/rbac.py index e58f4b0d40e..fea3be4584c 100644 --- a/src/ai/backend/manager/api/adapters/rbac.py +++ b/src/ai/backend/manager/api/adapters/rbac.py @@ -690,9 +690,9 @@ async def create_permission(self, input: CreatePermissionInputDTO) -> Permission creator: Creator[PermissionRow] = Creator( spec=PermissionCreatorSpec( role_id=input.role_id, - scope_type=RBACElementType(input.scope_type).to_scope_type(), + scope_type=RBACElementType(input.scope_type), scope_id=input.scope_id, - entity_type=RBACElementType(input.entity_type).to_entity_type(), + entity_type=RBACElementType(input.entity_type), operation=InternalOperationType(input.operation), ) ) @@ -707,7 +707,7 @@ async def update_permission(self, input: UpdatePermissionInputDTO) -> Permission """Update an existing scoped permission.""" spec = PermissionUpdaterSpec( scope_type=( - OptionalState.update(RBACElementType(input.scope_type).to_scope_type()) + OptionalState.update(RBACElementType(input.scope_type)) if input.scope_type is not None else OptionalState.nop() ), @@ -717,7 +717,7 @@ async def update_permission(self, input: UpdatePermissionInputDTO) -> Permission else OptionalState.nop() ), entity_type=( - OptionalState.update(RBACElementType(input.entity_type).to_entity_type()) + OptionalState.update(RBACElementType(input.entity_type)) if input.entity_type is not None else OptionalState.nop() ), @@ -878,11 +878,13 @@ def _convert_permission_filter(self, f: PermissionFilterDTO) -> list[QueryCondit if f.role_id is not None: conditions.append(ScopedPermissionConditions.by_role_id(f.role_id)) if f.scope_type is not None: - scope_type = RBACElementType(f.scope_type).to_scope_type() - conditions.append(ScopedPermissionConditions.by_scope_type(scope_type)) + conditions.append( + ScopedPermissionConditions.by_scope_type(RBACElementType(f.scope_type)) + ) if f.entity_type is not None: - entity_type = RBACElementType(f.entity_type).to_entity_type() - conditions.append(ScopedPermissionConditions.by_entity_type(entity_type)) + conditions.append( + ScopedPermissionConditions.by_entity_type(RBACElementType(f.entity_type)) + ) if f.AND: for sub in f.AND: conditions.extend(self._convert_permission_filter(sub)) @@ -1112,8 +1114,7 @@ def _convert_assignment_orders(orders: list[RoleAssignmentOrderByDTO]) -> list[Q def _convert_entity_filter(self, f: EntityFilterDTO) -> list[QueryCondition]: conditions: list[QueryCondition] = [] if f.entity_type is not None: - entity_type = RBACElementType(f.entity_type).to_entity_type() - conditions.append(EntityScopeConditions.by_entity_type(entity_type)) + conditions.append(EntityScopeConditions.by_entity_type(RBACElementType(f.entity_type))) if f.entity_id is not None: condition = self.convert_string_filter( f.entity_id, diff --git a/tests/component/rbac/test_rbac_permission.py b/tests/component/rbac/test_rbac_permission.py index d03fbed6aa9..52732b290b2 100644 --- a/tests/component/rbac/test_rbac_permission.py +++ b/tests/component/rbac/test_rbac_permission.py @@ -5,7 +5,11 @@ import pytest -from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, OperationType, ScopeType +from ai.backend.common.data.permission.types import ( + OperationType, + RBACElementType, + ScopeType, +) from ai.backend.manager.data.permission.id import ObjectId, ScopeId from ai.backend.manager.data.permission.object_permission import ObjectPermissionData from ai.backend.manager.data.permission.permission import PermissionData @@ -71,14 +75,15 @@ async def test_create_basic_permission( self, permission_controller_processors: PermissionControllerProcessors, target_role: Any, + domain_fixture: str, ) -> None: """S-CREATE-1: Create basic permission with valid params → PermissionData returned.""" creator = Creator( spec=PermissionCreatorSpec( role_id=target_role.role.id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.SESSION, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.SESSION, operation=OperationType.READ, ) ) @@ -88,7 +93,7 @@ async def test_create_basic_permission( assert isinstance(result.data, PermissionData) assert result.data.role_id == target_role.role.id - assert result.data.scope_type == ScopeType.GLOBAL + assert result.data.scope_type == ScopeType.DOMAIN assert result.data.entity_type == EntityType.SESSION assert result.data.operation == OperationType.READ @@ -101,12 +106,18 @@ async def test_create_permissions_with_various_combinations( self, permission_controller_processors: PermissionControllerProcessors, target_role: Any, + domain_fixture: str, ) -> None: """S-CREATE-2: Create permissions with various scope/entity/operation combinations.""" - combos: list[tuple[ScopeType, str, EntityType, OperationType]] = [ - (ScopeType.GLOBAL, GLOBAL_SCOPE_ID, EntityType.SESSION, OperationType.READ), - (ScopeType.GLOBAL, GLOBAL_SCOPE_ID, EntityType.IMAGE, OperationType.UPDATE), - (ScopeType.GLOBAL, GLOBAL_SCOPE_ID, EntityType.VFOLDER, OperationType.SOFT_DELETE), + combos: list[tuple[RBACElementType, str, RBACElementType, OperationType]] = [ + (RBACElementType.DOMAIN, domain_fixture, RBACElementType.SESSION, OperationType.READ), + (RBACElementType.DOMAIN, domain_fixture, RBACElementType.IMAGE, OperationType.UPDATE), + ( + RBACElementType.DOMAIN, + domain_fixture, + RBACElementType.VFOLDER, + OperationType.SOFT_DELETE, + ), ] created_ids: list[uuid.UUID] = [] @@ -124,7 +135,7 @@ async def test_create_permissions_with_various_combinations( ) ) ) - assert result.data.entity_type == entity_type + assert result.data.entity_type == entity_type.to_entity_type() assert result.data.operation == operation assert result.data.role_id == target_role.role.id created_ids.append(result.data.id) @@ -139,13 +150,14 @@ async def test_create_duplicate_permission_raises_unique_constraint( self, permission_controller_processors: PermissionControllerProcessors, target_role: Any, + domain_fixture: str, ) -> None: """F-BIZ-4: Create duplicate permission → unique constraint error.""" spec = PermissionCreatorSpec( role_id=target_role.role.id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.VFOLDER, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.VFOLDER, operation=OperationType.READ, ) @@ -172,6 +184,7 @@ async def test_delete_existing_permission( self, permission_controller_processors: PermissionControllerProcessors, target_role: Any, + domain_fixture: str, ) -> None: """S-DELETE-1: Delete existing permission → deletion response.""" create_result = await permission_controller_processors.create_permission.wait_for_complete( @@ -179,9 +192,9 @@ async def test_delete_existing_permission( creator=Creator( spec=PermissionCreatorSpec( role_id=target_role.role.id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.SESSION, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.SESSION, operation=OperationType.HARD_DELETE, ) ) @@ -200,6 +213,7 @@ async def test_deleted_permission_no_longer_exists( self, permission_controller_processors: PermissionControllerProcessors, target_role: Any, + domain_fixture: str, ) -> None: """S-DELETE-2: Verify deleted permission no longer exists.""" create_result = await permission_controller_processors.create_permission.wait_for_complete( @@ -207,9 +221,9 @@ async def test_deleted_permission_no_longer_exists( creator=Creator( spec=PermissionCreatorSpec( role_id=target_role.role.id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.IMAGE, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.IMAGE, operation=OperationType.SOFT_DELETE, ) ) @@ -255,7 +269,7 @@ async def test_create_object_permission( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=target_role.role.id, - entity_type=EntityType.SESSION, + entity_type=RBACElementType.SESSION, entity_id=entity_id, operation=OperationType.READ, ) @@ -291,7 +305,7 @@ async def test_create_multiple_object_permissions_for_same_role( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=target_role.role.id, - entity_type=EntityType.VFOLDER, + entity_type=RBACElementType.VFOLDER, entity_id=entity_id, operation=OperationType.READ, ) @@ -320,7 +334,7 @@ async def test_create_object_permission_with_nonexistent_role_succeeds( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=uuid.uuid4(), # non-existent role — no FK constraint enforced - entity_type=EntityType.SESSION, + entity_type=RBACElementType.SESSION, entity_id=str(uuid.uuid4()), operation=OperationType.READ, ) @@ -352,7 +366,7 @@ async def test_delete_object_permission( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=target_role.role.id, - entity_type=EntityType.IMAGE, + entity_type=RBACElementType.IMAGE, entity_id=entity_id, operation=OperationType.READ, ) @@ -394,6 +408,7 @@ async def test_user_with_matching_object_permission_returns_true( permission_repo: PermissionControllerRepository, role_factory: RoleFactory, admin_user_fixture: Any, + domain_fixture: str, ) -> None: """S-ENTITY-1: User with matching role+ObjectPermission → True.""" role = await role_factory() @@ -407,9 +422,9 @@ async def test_user_with_matching_object_permission_returns_true( creator=Creator( spec=PermissionCreatorSpec( role_id=role_id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.SESSION, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.SESSION, operation=OperationType.READ, ) ) @@ -422,7 +437,7 @@ async def test_user_with_matching_object_permission_returns_true( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=role_id, - entity_type=EntityType.SESSION, + entity_type=RBACElementType.SESSION, entity_id=entity_id, operation=OperationType.READ, ) @@ -467,6 +482,7 @@ async def test_user_without_matching_object_permission_returns_false( permission_repo: PermissionControllerRepository, role_factory: RoleFactory, admin_user_fixture: Any, + domain_fixture: str, ) -> None: """S-ENTITY-2: User without matching ObjectPermission → False.""" role = await role_factory() @@ -481,9 +497,9 @@ async def test_user_without_matching_object_permission_returns_false( creator=Creator( spec=PermissionCreatorSpec( role_id=role_id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.SESSION, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.SESSION, operation=OperationType.READ, ) ) @@ -496,7 +512,7 @@ async def test_user_without_matching_object_permission_returns_false( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=role_id, - entity_type=EntityType.SESSION, + entity_type=RBACElementType.SESSION, entity_id=other_entity_id, operation=OperationType.READ, ) @@ -560,6 +576,7 @@ async def test_user_with_permission_in_scope_returns_true( permission_repo: PermissionControllerRepository, role_factory: RoleFactory, admin_user_fixture: Any, + domain_fixture: str, ) -> None: """S-SCOPE-1: User has permission in target scope → True.""" role = await role_factory() @@ -571,9 +588,9 @@ async def test_user_with_permission_in_scope_returns_true( creator=Creator( spec=PermissionCreatorSpec( role_id=role_id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.SESSION, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.SESSION, operation=OperationType.READ, ) ) @@ -589,7 +606,7 @@ async def test_user_with_permission_in_scope_returns_true( ScopePermissionCheckInput( user_id=user_id, target_entity_type=EntityType.SESSION, - target_scope_id=ScopeId(scope_type=ScopeType.GLOBAL, scope_id=GLOBAL_SCOPE_ID), + target_scope_id=ScopeId(scope_type=ScopeType.DOMAIN, scope_id=domain_fixture), operation=OperationType.READ, ) ) @@ -607,13 +624,14 @@ async def test_user_with_permission_in_scope_returns_true( async def test_user_without_permission_in_scope_returns_false( self, permission_repo: PermissionControllerRepository, + domain_fixture: str, ) -> None: """S-SCOPE-2: User lacks permission in target scope → False.""" has_perm = await permission_repo.check_permission_in_scope( ScopePermissionCheckInput( user_id=uuid.uuid4(), # random user with no roles target_entity_type=EntityType.SESSION, - target_scope_id=ScopeId(scope_type=ScopeType.GLOBAL, scope_id=GLOBAL_SCOPE_ID), + target_scope_id=ScopeId(scope_type=ScopeType.DOMAIN, scope_id=domain_fixture), operation=OperationType.READ, ) ) @@ -630,6 +648,7 @@ async def test_batch_check_returns_correct_mapping( permission_repo: PermissionControllerRepository, role_factory: RoleFactory, admin_user_fixture: Any, + domain_fixture: str, ) -> None: """S-BATCH-1: Batch check returns correct per-object bool mapping.""" role = await role_factory() @@ -645,9 +664,9 @@ async def test_batch_check_returns_correct_mapping( creator=Creator( spec=PermissionCreatorSpec( role_id=role_id, - scope_type=ScopeType.GLOBAL, - scope_id=GLOBAL_SCOPE_ID, - entity_type=EntityType.SESSION, + scope_type=RBACElementType.DOMAIN, + scope_id=domain_fixture, + entity_type=RBACElementType.SESSION, operation=OperationType.READ, ) ) @@ -660,7 +679,7 @@ async def test_batch_check_returns_correct_mapping( creator=Creator( spec=ObjectPermissionCreatorSpec( role_id=role_id, - entity_type=EntityType.SESSION, + entity_type=RBACElementType.SESSION, entity_id=entity_id_with_perm, operation=OperationType.READ, ) diff --git a/tests/unit/manager/api/rbac/test_scope_handlers.py b/tests/unit/manager/api/rbac/test_scope_handlers.py index 529d4a10a13..ffdd71a995b 100644 --- a/tests/unit/manager/api/rbac/test_scope_handlers.py +++ b/tests/unit/manager/api/rbac/test_scope_handlers.py @@ -13,7 +13,7 @@ import pytest from ai.backend.common.api_handlers import BodyParam, PathParam -from ai.backend.common.data.permission.types import ScopeType +from ai.backend.common.data.permission.types import RBACElementType, ScopeType from ai.backend.common.dto.manager.rbac.path import SearchScopesPathParam from ai.backend.common.dto.manager.rbac.request import SearchScopesRequest from ai.backend.manager.api.rest.rbac.handler import RBACHandler @@ -65,8 +65,11 @@ def make_test_user_ctx() -> UserContext: class TestGetScopeTypesHandler: """Tests for get_scope_types handler.""" - # Constants - EXPECTED_SCOPE_TYPES_COUNT = len(ScopeType) + # Count only RBACElementType members convertible to ScopeType + # (the handler filters out non-convertible element types) + EXPECTED_SCOPE_TYPES_COUNT = len([ + et for et in RBACElementType if et.value in {st.value for st in ScopeType} + ]) @pytest.fixture def mock_permission_controller(self) -> MagicMock: @@ -83,7 +86,7 @@ async def test_get_scope_types_returns_scope_types( """Test get_scope_types returns all scope types for superadmin.""" handler = make_test_handler(mock_permission_controller) ctx = make_test_superadmin_ctx() - action_result = GetScopeTypesActionResult(scope_types=list(ScopeType)) + action_result = GetScopeTypesActionResult(element_types=list(RBACElementType)) mock_permission_controller.get_scope_types.wait_for_complete.return_value = action_result response = await handler.get_scope_types(ctx=ctx) diff --git a/tests/unit/manager/repositories/permission_controller/test_search_element_associations.py b/tests/unit/manager/repositories/permission_controller/test_search_element_associations.py index eea23186a1b..4f54ef5fef6 100644 --- a/tests/unit/manager/repositories/permission_controller/test_search_element_associations.py +++ b/tests/unit/manager/repositories/permission_controller/test_search_element_associations.py @@ -12,7 +12,7 @@ import pytest -from ai.backend.manager.data.permission.types import EntityType, ScopeType +from ai.backend.manager.data.permission.types import EntityType, RBACElementType, ScopeType from ai.backend.manager.models.rbac_models import UserRoleRow from ai.backend.manager.models.rbac_models.association_scopes_entities import ( AssociationScopesEntitiesRow, @@ -113,7 +113,7 @@ async def test_search_element_associations_items_have_correct_data( """Returned items should contain correct scope and entity data.""" querier = BatchQuerier( conditions=[ - EntityScopeConditions.by_entity_type(EntityType.IMAGE), + EntityScopeConditions.by_entity_type(RBACElementType.IMAGE), ], orders=[], pagination=OffsetPagination(limit=10, offset=0), diff --git a/tests/unit/manager/repositories/permission_controller/test_search_permissions.py b/tests/unit/manager/repositories/permission_controller/test_search_permissions.py index cbefd78a1a1..f5c95126177 100644 --- a/tests/unit/manager/repositories/permission_controller/test_search_permissions.py +++ b/tests/unit/manager/repositories/permission_controller/test_search_permissions.py @@ -14,6 +14,7 @@ from ai.backend.manager.data.permission.types import ( EntityType, OperationType, + RBACElementType, ScopeType, ) from ai.backend.manager.models.rbac_models import UserRoleRow @@ -118,7 +119,7 @@ async def test_search_permissions_with_entity_type_filter( ) -> None: querier = BatchQuerier( conditions=[ - ScopedPermissionConditions.by_entity_type(EntityType.VFOLDER), + ScopedPermissionConditions.by_entity_type(RBACElementType.VFOLDER), ], orders=[], pagination=OffsetPagination(limit=10, offset=0), @@ -234,7 +235,7 @@ async def test_search_object_permissions_with_entity_type_filter( querier = BatchQuerier( conditions=[ ObjectPermissionConditions.by_role_id(role_with_object_permissions.role_id), - ObjectPermissionConditions.by_entity_type(EntityType.VFOLDER), + ObjectPermissionConditions.by_entity_type(RBACElementType.VFOLDER), ], orders=[], pagination=OffsetPagination(limit=10, offset=0), diff --git a/tests/unit/manager/repositories/permission_controller/test_search_scopes.py b/tests/unit/manager/repositories/permission_controller/test_search_scopes.py index 77f2a7ff890..a088755bcd9 100644 --- a/tests/unit/manager/repositories/permission_controller/test_search_scopes.py +++ b/tests/unit/manager/repositories/permission_controller/test_search_scopes.py @@ -11,7 +11,7 @@ import pytest from ai.backend.common.data.filter_specs import StringMatchSpec -from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, ScopeType +from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, RBACElementType, ScopeType from ai.backend.common.types import ResourceSlot from ai.backend.manager.models.agent import AgentRow from ai.backend.manager.models.deployment_auto_scaling_policy import DeploymentAutoScalingPolicyRow @@ -160,7 +160,9 @@ async def test_search_domain_scopes_returns_domains( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) assert result.total_count == len(sample_domains) assert len(result.items) == len(sample_domains) @@ -181,7 +183,9 @@ async def test_search_domain_scopes_with_name_contains_filter( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) # sample_domains has "test-domain-alpha", "test-domain-beta", "prod-domain" # Only domains containing "test" should be returned @@ -203,7 +207,9 @@ async def test_search_domain_scopes_with_name_equals_filter( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) assert result.total_count == 1 assert result.items[0].name == target_name @@ -221,7 +227,9 @@ async def test_search_domain_scopes_with_name_starts_with_filter( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) assert result.total_count == 2 for item in result.items: @@ -239,7 +247,9 @@ async def test_search_domain_scopes_with_ordering_name_ascending( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) names = [item.name for item in result.items] assert names == sorted(names) @@ -256,7 +266,9 @@ async def test_search_domain_scopes_with_ordering_name_descending( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) names = [item.name for item in result.items] assert names == sorted(names, reverse=True) @@ -274,7 +286,7 @@ async def test_search_domain_scopes_with_pagination( pagination=OffsetPagination(limit=5, offset=0), ) result_page1 = await permission_controller_repository.search_scopes( - ScopeType.DOMAIN, querier_page1 + RBACElementType.DOMAIN, querier_page1 ) assert len(result_page1.items) == 5 @@ -289,7 +301,7 @@ async def test_search_domain_scopes_with_pagination( pagination=OffsetPagination(limit=5, offset=5), ) result_page2 = await permission_controller_repository.search_scopes( - ScopeType.DOMAIN, querier_page2 + RBACElementType.DOMAIN, querier_page2 ) assert len(result_page2.items) == 5 @@ -445,7 +457,9 @@ async def test_search_project_scopes_returns_groups( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.PROJECT, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.PROJECT, querier + ) assert result.total_count == len(sample_projects) for item in result.items: @@ -464,7 +478,9 @@ async def test_search_project_scopes_with_name_contains_filter( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.PROJECT, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.PROJECT, querier + ) assert result.total_count == 1 assert "alpha" in result.items[0].name.lower() @@ -481,7 +497,9 @@ async def test_search_project_scopes_with_ordering( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.PROJECT, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.PROJECT, querier + ) names = [item.name for item in result.items] assert names == sorted(names) @@ -498,7 +516,9 @@ async def test_search_project_scopes_with_pagination( pagination=OffsetPagination(limit=5, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.PROJECT, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.PROJECT, querier + ) assert len(result.items) == 5 assert result.total_count == 15 @@ -655,7 +675,7 @@ async def test_search_user_scopes_returns_users( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.USER, querier) + result = await permission_controller_repository.search_scopes(RBACElementType.USER, querier) assert result.total_count == len(sample_users) for item in result.items: @@ -675,7 +695,7 @@ async def test_search_user_scopes_filters_username_or_email( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.USER, querier) + result = await permission_controller_repository.search_scopes(RBACElementType.USER, querier) # Users with "example" in email: alpha@example.com, beta@example.com assert result.total_count == 2 @@ -692,7 +712,7 @@ async def test_search_user_scopes_with_ordering( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.USER, querier) + result = await permission_controller_repository.search_scopes(RBACElementType.USER, querier) names = [item.name for item in result.items] assert names == sorted(names) @@ -709,7 +729,7 @@ async def test_search_user_scopes_with_pagination( pagination=OffsetPagination(limit=5, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.USER, querier) + result = await permission_controller_repository.search_scopes(RBACElementType.USER, querier) assert len(result.items) == 5 assert result.total_count == 15 @@ -766,13 +786,7 @@ async def test_search_scopes_global_returns_static_result( permission_controller_repository: PermissionControllerRepository, ) -> None: """Test global scope returns single static result.""" - querier = BatchQuerier( - conditions=[], - orders=[], - pagination=OffsetPagination(limit=10, offset=0), - ) - - result = await permission_controller_repository.search_scopes(ScopeType.GLOBAL, querier) + result = permission_controller_repository.get_global_scope() assert result.total_count == 1 assert len(result.items) == 1 @@ -864,7 +878,9 @@ async def test_search_scopes_empty_result( pagination=OffsetPagination(limit=10, offset=0), ) - result = await permission_controller_repository.search_scopes(ScopeType.DOMAIN, querier) + result = await permission_controller_repository.search_scopes( + RBACElementType.DOMAIN, querier + ) assert result.total_count == 0 assert len(result.items) == 0 diff --git a/tests/unit/manager/services/permission_controller/test_permission_controller_service.py b/tests/unit/manager/services/permission_controller/test_permission_controller_service.py index 5462d7e4d7f..cdafcd33b76 100644 --- a/tests/unit/manager/services/permission_controller/test_permission_controller_service.py +++ b/tests/unit/manager/services/permission_controller/test_permission_controller_service.py @@ -15,6 +15,7 @@ from ai.backend.common.data.permission.types import ( EntityType, OperationType, + RBACElementType, RelationType, RoleSource, ScopeType, @@ -921,10 +922,10 @@ async def test_get_entity_types_returns_all( action = GetEntityTypesAction() result = await service.get_entity_types(action) - expected = list(EntityType) - assert len(result.entity_types) == len(expected) + expected = list(RBACElementType) + assert len(result.element_types) == len(expected) for et in expected: - assert et in result.entity_types + assert et in result.element_types class TestSearchEntities: diff --git a/tests/unit/manager/services/permission_controller/test_search_scopes_service.py b/tests/unit/manager/services/permission_controller/test_search_scopes_service.py index 7f6ba0170d6..6c9cb7ad5f7 100644 --- a/tests/unit/manager/services/permission_controller/test_search_scopes_service.py +++ b/tests/unit/manager/services/permission_controller/test_search_scopes_service.py @@ -10,7 +10,7 @@ import pytest -from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, ScopeType +from ai.backend.common.data.permission.types import RBACElementType, ScopeType from ai.backend.manager.data.permission.id import ScopeId from ai.backend.manager.data.permission.types import ScopeData, ScopeListResult from ai.backend.manager.repositories.base import BatchQuerier, OffsetPagination @@ -54,10 +54,10 @@ async def test_get_scope_types_returns_all_scope_types( result = await service.get_scope_types(action) - expected_types = list(ScopeType) - assert len(result.scope_types) == len(expected_types) + expected_types = list(RBACElementType) + assert len(result.element_types) == len(expected_types) for scope_type in expected_types: - assert scope_type in result.scope_types + assert scope_type in result.element_types class TestSearchScopes: @@ -105,11 +105,11 @@ async def test_search_scopes_calls_repository( orders=[], pagination=OffsetPagination(limit=limit, offset=offset), ) - action = SearchScopesAction(scope_type=ScopeType.DOMAIN, querier=querier) + action = SearchScopesAction(element_type=RBACElementType.DOMAIN, querier=querier) result = await service.search_scopes(action) - mock_repository.search_scopes.assert_called_once_with(ScopeType.DOMAIN, querier) + mock_repository.search_scopes.assert_called_once_with(RBACElementType.DOMAIN, querier) assert result.result.total_count == total_count assert len(result.result.items) == total_count assert result.result.items[0].id.scope_type == ScopeType.DOMAIN @@ -150,7 +150,7 @@ async def test_search_scopes_returns_action_result( orders=[], pagination=OffsetPagination(limit=limit, offset=offset), ) - action = SearchScopesAction(scope_type=ScopeType.PROJECT, querier=querier) + action = SearchScopesAction(element_type=RBACElementType.PROJECT, querier=querier) result = await service.search_scopes(action) @@ -158,38 +158,3 @@ async def test_search_scopes_returns_action_result( assert len(result.result.items) == items_count assert result.result.has_next_page is True assert result.result.has_previous_page is False - - async def test_search_scopes_global_type( - self, - service: PermissionControllerService, - mock_repository: MagicMock, - ) -> None: - """Test search_scopes handles global scope type.""" - total_count = 1 - limit = 10 - offset = 0 - mock_result = ScopeListResult( - items=[ - ScopeData( - id=ScopeId(scope_type=ScopeType.GLOBAL, scope_id=GLOBAL_SCOPE_ID), - name=GLOBAL_SCOPE_ID, - ) - ], - total_count=total_count, - has_next_page=False, - has_previous_page=False, - ) - mock_repository.search_scopes.return_value = mock_result - - querier = BatchQuerier( - conditions=[], - orders=[], - pagination=OffsetPagination(limit=limit, offset=offset), - ) - action = SearchScopesAction(scope_type=ScopeType.GLOBAL, querier=querier) - - result = await service.search_scopes(action) - - mock_repository.search_scopes.assert_called_once_with(ScopeType.GLOBAL, querier) - assert result.result.total_count == total_count - assert result.result.items[0].id.scope_type == ScopeType.GLOBAL From 208e147061e6b8a9da5e9fe1f30411c7e7ad0df3 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Tue, 24 Mar 2026 03:00:18 +0900 Subject: [PATCH 5/6] fix(BA-4525): Raise error instead of branching on GLOBAL scope in search_scopes Remove GLOBAL scope early-return from handler (scope_type.to_element() now raises RBACTypeConversionError naturally), remove GLOBAL branch from ScopeAdapter, remove get_global_scope() from repository, and delete the corresponding test. Co-Authored-By: Claude Opus 4.6 --- .../backend/manager/api/rest/rbac/handler.py | 18 ----------------- .../manager/api/rest/rbac/scope_adapter.py | 7 ------- .../permission_controller/repository.py | 20 ++----------------- .../test_search_scopes.py | 17 +--------------- 4 files changed, 3 insertions(+), 59 deletions(-) diff --git a/src/ai/backend/manager/api/rest/rbac/handler.py b/src/ai/backend/manager/api/rest/rbac/handler.py index 3494a6bffd4..001c0213d56 100644 --- a/src/ai/backend/manager/api/rest/rbac/handler.py +++ b/src/ai/backend/manager/api/rest/rbac/handler.py @@ -12,7 +12,6 @@ from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam from ai.backend.common.data.permission.types import ( - GLOBAL_SCOPE_ID, EntityType, ScopeType, ) @@ -50,9 +49,7 @@ SearchScopesResponse, ) from ai.backend.common.exception import RBACTypeConversionError -from ai.backend.manager.data.permission.id import ScopeId from ai.backend.manager.data.permission.role import UserRoleAssignmentInput, UserRoleRevocationInput -from ai.backend.manager.data.permission.types import ScopeData from ai.backend.manager.dto.context import UserContext from ai.backend.manager.errors.permission import NotEnoughPermission from ai.backend.manager.models.rbac_models.role import RoleRow @@ -326,21 +323,6 @@ async def search_scopes( raise NotEnoughPermission("Only superadmin can search scopes.") scope_type = path.parsed.scope_type - # Handle GLOBAL scope as a static early-return (GLOBAL is not in RBACElementType) - if scope_type.value == GLOBAL_SCOPE_ID: - global_result = SearchScopesResponse( - items=[ - self._scope_adapter.convert_to_dto( - ScopeData( - id=ScopeId(scope_type=ScopeType.GLOBAL, scope_id=GLOBAL_SCOPE_ID), - name=GLOBAL_SCOPE_ID, - ) - ) - ], - pagination=PaginationInfo(total=1, offset=0, limit=1), - ) - return APIResponse.build(status_code=HTTPStatus.OK, response_model=global_result) - element_type = scope_type.to_element() querier = self._scope_adapter.build_querier(scope_type, body.parsed) action = SearchScopesAction(element_type=element_type, querier=querier) diff --git a/src/ai/backend/manager/api/rest/rbac/scope_adapter.py b/src/ai/backend/manager/api/rest/rbac/scope_adapter.py index 1d3578fcdef..a7243e4ef7a 100644 --- a/src/ai/backend/manager/api/rest/rbac/scope_adapter.py +++ b/src/ai/backend/manager/api/rest/rbac/scope_adapter.py @@ -41,8 +41,6 @@ class ScopeAdapter(BaseFilterAdapter): def build_querier(self, scope_type: ScopeType, request: SearchScopesRequest) -> BatchQuerier: """Build a BatchQuerier based on scope type.""" match scope_type: - case ScopeType.GLOBAL: - return self._build_global_scope_querier(request) case ScopeType.DOMAIN: return self._build_domain_scope_querier(request) case ScopeType.PROJECT: @@ -78,11 +76,6 @@ def _build_user_scope_querier(self, request: SearchScopesRequest) -> BatchQuerie return BatchQuerier(conditions=conditions, orders=orders, pagination=pagination) - def _build_global_scope_querier(self, request: SearchScopesRequest) -> BatchQuerier: - """Build a BatchQuerier for global scope (no filtering needed).""" - pagination = OffsetPagination(limit=request.limit, offset=request.offset) - return BatchQuerier(conditions=[], orders=[], pagination=pagination) - def _convert_domain_filter(self, filter: ScopeFilter) -> list[QueryCondition]: """Convert scope filter to domain query conditions.""" conditions: list[QueryCondition] = [] diff --git a/src/ai/backend/manager/repositories/permission_controller/repository.py b/src/ai/backend/manager/repositories/permission_controller/repository.py index b9ef2d70a54..c3a394a9958 100644 --- a/src/ai/backend/manager/repositories/permission_controller/repository.py +++ b/src/ai/backend/manager/repositories/permission_controller/repository.py @@ -4,14 +4,14 @@ from collections.abc import Mapping from typing import cast -from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, OperationType, RBACElementType +from ai.backend.common.data.permission.types import OperationType, RBACElementType from ai.backend.common.exception import BackendAIError from ai.backend.common.metrics.metric import DomainType, LayerType from ai.backend.common.resilience.policies.metrics import MetricArgs, MetricPolicy from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy from ai.backend.common.resilience.resilience import Resilience from ai.backend.manager.data.permission.entity import ElementAssociationListResult, EntityListResult -from ai.backend.manager.data.permission.id import ObjectId, ScopeId +from ai.backend.manager.data.permission.id import ObjectId from ai.backend.manager.data.permission.object_permission import ( ObjectPermissionData, ObjectPermissionListResult, @@ -40,9 +40,7 @@ ) from ai.backend.manager.data.permission.types import ( RBACElementRef, - ScopeData, ScopeListResult, - ScopeType, ) from ai.backend.manager.models.rbac_models.permission.object_permission import ObjectPermissionRow from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow @@ -303,20 +301,6 @@ async def search_users_assigned_to_role( querier=querier, ) - def get_global_scope(self) -> ScopeListResult: - """Get the global scope as a static result.""" - return ScopeListResult( - items=[ - ScopeData( - id=ScopeId(scope_type=ScopeType.GLOBAL, scope_id=GLOBAL_SCOPE_ID), - name=GLOBAL_SCOPE_ID, - ) - ], - total_count=1, - has_next_page=False, - has_previous_page=False, - ) - @permission_controller_repository_resilience.apply() async def search_scopes( self, diff --git a/tests/unit/manager/repositories/permission_controller/test_search_scopes.py b/tests/unit/manager/repositories/permission_controller/test_search_scopes.py index a088755bcd9..6736be6307a 100644 --- a/tests/unit/manager/repositories/permission_controller/test_search_scopes.py +++ b/tests/unit/manager/repositories/permission_controller/test_search_scopes.py @@ -11,7 +11,7 @@ import pytest from ai.backend.common.data.filter_specs import StringMatchSpec -from ai.backend.common.data.permission.types import GLOBAL_SCOPE_ID, RBACElementType, ScopeType +from ai.backend.common.data.permission.types import RBACElementType, ScopeType from ai.backend.common.types import ResourceSlot from ai.backend.manager.models.agent import AgentRow from ai.backend.manager.models.deployment_auto_scaling_policy import DeploymentAutoScalingPolicyRow @@ -781,21 +781,6 @@ def permission_controller_repository( """Create PermissionControllerRepository instance.""" return PermissionControllerRepository(db_with_scope_tables) - async def test_search_scopes_global_returns_static_result( - self, - permission_controller_repository: PermissionControllerRepository, - ) -> None: - """Test global scope returns single static result.""" - result = permission_controller_repository.get_global_scope() - - assert result.total_count == 1 - assert len(result.items) == 1 - assert result.items[0].id.scope_type == ScopeType.GLOBAL - assert result.items[0].id.scope_id == GLOBAL_SCOPE_ID - assert result.items[0].name == GLOBAL_SCOPE_ID - assert result.has_next_page is False - assert result.has_previous_page is False - class TestSearchScopesEmptyResult: """Tests for empty result handling.""" From 228c7a7b54b622da29c8f29c4d4093e217cb1b19 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Tue, 24 Mar 2026 03:37:54 +0900 Subject: [PATCH 6/6] fix(BA-4525): Update scope adapter/handler tests for GLOBAL removal - Change GLOBAL scope adapter test to verify NotImplementedError is raised - Fix search_scopes handler test to assert on element_type instead of scope_type Co-Authored-By: Claude Opus 4.6 --- .../manager/api/rbac/test_scope_adapter.py | 30 +++++-------------- .../manager/api/rbac/test_scope_handlers.py | 2 +- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/tests/unit/manager/api/rbac/test_scope_adapter.py b/tests/unit/manager/api/rbac/test_scope_adapter.py index 9409f62351d..67f4c63480a 100644 --- a/tests/unit/manager/api/rbac/test_scope_adapter.py +++ b/tests/unit/manager/api/rbac/test_scope_adapter.py @@ -216,31 +216,17 @@ def test_build_querier_user_scope(self, adapter: ScopeAdapter) -> None: assert len(querier.conditions) == 1 - def test_build_querier_global_scope(self, adapter: ScopeAdapter) -> None: - """Test building querier for global scope returns empty querier.""" - limit = 10 - offset = 0 + def test_build_querier_global_scope_raises(self, adapter: ScopeAdapter) -> None: + """Test building querier for global scope raises NotImplementedError.""" request = SearchScopesRequest( - filter=ScopeFilter( - name=StringFilter( - i_contains="anything", - ) - ), - order=[ - ScopeOrder( - field=ScopeOrderField.NAME, - direction=OrderDirection.ASC, - ) - ], - limit=limit, - offset=offset, + filter=None, + order=[], + limit=10, + offset=0, ) - querier = adapter.build_querier(ScopeType.GLOBAL, request) - - # Global scope ignores filters and orders - assert querier.conditions == [] - assert querier.orders == [] + with pytest.raises(NotImplementedError): + adapter.build_querier(ScopeType.GLOBAL, request) class TestScopeAdapterConvertToDTO: diff --git a/tests/unit/manager/api/rbac/test_scope_handlers.py b/tests/unit/manager/api/rbac/test_scope_handlers.py index ffdd71a995b..7c0d452421d 100644 --- a/tests/unit/manager/api/rbac/test_scope_handlers.py +++ b/tests/unit/manager/api/rbac/test_scope_handlers.py @@ -257,7 +257,7 @@ async def test_search_scopes_calls_processor_with_action( mock_permission_controller.search_scopes.wait_for_complete.assert_called_once() call_args = mock_permission_controller.search_scopes.wait_for_complete.call_args action = call_args[0][0] - assert action.scope_type == self.TEST_SCOPE_TYPE + assert action.element_type == self.TEST_SCOPE_TYPE.to_element() async def test_search_scopes_rejects_non_superadmin( self,