Skip to content
Closed
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/9999.enhance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refactor RBAC layers to use unified RBACElementType enum replacing ScopeType and EntityType
18 changes: 11 additions & 7 deletions src/ai/backend/common/dto/manager/rbac/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import Field

from ai.backend.common.api_handlers import BaseRequestModel
from ai.backend.common.data.permission.types import EntityType, ScopeType
from ai.backend.common.data.permission.types import RBACElementType

__all__ = (
"DeleteObjectPermissionPathParam",
Expand Down Expand Up @@ -54,24 +54,28 @@ class DeleteObjectPermissionPathParam(BaseRequestModel):


class SearchScopesPathParam(BaseRequestModel):
"""Path parameter for searching scopes."""
"""Path parameter for searching scopes.

scope_type: ScopeType = Field(
description="Scope types", examples=["domain", "project", "user", "global"]
``scope_type`` is ``None`` when the caller requests the GLOBAL scope,
which has no ``RBACElementType`` equivalent.
"""

scope_type: RBACElementType | None = Field(
description="Scope types", examples=["domain", "project", "user"]
)
Comment on lines +57 to 65
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking API change: scope_type is typed as RBACElementType | None, but if a client sends "global" in the URL path, Pydantic will raise a validation error because "global" is not a valid RBACElementType value and path parameters won't naturally become None. The None case can only be reached if the path parameter is somehow omitted, which typically isn't how path parameters work. You likely need a custom validator or a separate union type that maps "global" to None.

Copilot uses AI. Check for mistakes.


class SearchEntitiesPathParam(BaseRequestModel):
"""Path parameter for searching entities within a scope."""

scope_type: ScopeType = Field(
description="Scope type", examples=["domain", "project", "user", "global"]
scope_type: RBACElementType = Field(
description="Scope type", examples=["domain", "project", "user"]
)
scope_id: str = Field(
description="Scope ID (domain name, project UUID, or user UUID)",
examples=["default", "550e8400-e29b-41d4-a716-446655440000"],
)
entity_type: EntityType = Field(
entity_type: RBACElementType = Field(
description="Entity type to search",
examples=["user", "vfolder", "session", "image"],
)
9 changes: 4 additions & 5 deletions src/ai/backend/common/dto/manager/rbac/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@
from pydantic import Field

from ai.backend.common.api_handlers import SENTINEL, BaseRequestModel, Sentinel
from ai.backend.common.data.permission.types import ScopeType
from ai.backend.common.data.permission.types import RBACElementType
from ai.backend.common.dto.manager.defs import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT
from ai.backend.common.dto.manager.query import StringFilter

from .types import (
AssignedUserOrderField,
EntityType,
OperationType,
OrderDirection,
PermissionStatus,
Expand Down Expand Up @@ -152,17 +151,17 @@ class CreatePermissionRequest(BaseRequestModel):
"""Request to create a permission."""

role_id: UUID = Field(description="Role ID for the permission")
scope_type: ScopeType = Field(description="Scope type for the permission")
scope_type: RBACElementType = Field(description="Scope type for the permission")
scope_id: str = Field(description="Scope ID for the permission")
entity_type: EntityType = Field(description="Entity type for the permission")
entity_type: RBACElementType = Field(description="Entity type for the permission")
operation: OperationType = Field(description="Operation type for the permission")


class CreateObjectPermissionRequest(BaseRequestModel):
"""Request to create an object permission for a role."""

role_id: UUID = Field(description="Role ID to add the object permission to")
entity_type: EntityType = Field(description="Entity type for the object permission")
entity_type: RBACElementType = Field(description="Entity type for the object permission")
entity_id: str = Field(description="Entity ID (e.g., project_id, user_id)")
operation: OperationType = Field(description="Operation type for the object permission")
status: PermissionStatus = Field(
Expand Down
16 changes: 8 additions & 8 deletions src/ai/backend/common/dto/manager/rbac/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from pydantic import BaseModel, Field

from ai.backend.common.api_handlers import BaseResponseModel
from ai.backend.common.data.permission.types import ScopeType
from ai.backend.common.data.permission.types import RBACElementType
from ai.backend.common.dto.manager.pagination import PaginationInfo

from .types import EntityType, OperationType, RoleSource, RoleStatus
from .types import OperationType, RoleSource, RoleStatus

__all__ = (
"AssignRoleResponse",
Expand Down Expand Up @@ -121,7 +121,7 @@ class PermissionDTO(BaseModel):
"""DTO for permission data."""

id: UUID = Field(description="Permission ID")
entity_type: EntityType = Field(description="Entity type")
entity_type: RBACElementType = Field(description="Entity type")
operation: OperationType = Field(description="Operation type")


Expand All @@ -130,7 +130,7 @@ class ObjectPermissionDTO(BaseModel):

id: UUID = Field(description="Object permission ID")
role_id: UUID = Field(description="Role ID")
entity_type: EntityType = Field(description="Entity type")
entity_type: RBACElementType = Field(description="Entity type")
entity_id: str = Field(description="Entity ID")
operation: OperationType = Field(description="Operation type")

Expand Down Expand Up @@ -162,13 +162,13 @@ class DeleteObjectPermissionResponse(BaseResponseModel):
class GetScopeTypesResponse(BaseResponseModel):
"""Response for getting available scope types."""

items: list[ScopeType] = Field(description="List of available scope types")
items: list[RBACElementType] = Field(description="List of available scope types")


class ScopeDTO(BaseModel):
"""DTO for scope data."""

scope_type: ScopeType = Field(description="Scope type")
scope_type: RBACElementType = Field(description="Scope type")
scope_id: str = Field(description="Scope ID (domain name, project UUID, or user UUID)")
name: str = Field(description="Scope display name")

Expand All @@ -183,13 +183,13 @@ class SearchScopesResponse(BaseResponseModel):
class GetEntityTypesResponse(BaseResponseModel):
"""Response for getting available entity types."""

items: list[EntityType] = Field(description="List of available entity types")
items: list[RBACElementType] = Field(description="List of available entity types")


class EntityDTO(BaseModel):
"""DTO for entity data."""

entity_type: EntityType = Field(description="Entity type")
entity_type: RBACElementType = Field(description="Entity type")
entity_id: str = Field(description="Entity ID")


Expand Down
4 changes: 1 addition & 3 deletions src/ai/backend/manager/api/gql/rbac/types/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,7 @@ def build_conditions(self) -> list[QueryCondition]:
conditions: list[QueryCondition] = []

if self.entity_type is not None:
conditions.append(
EntityScopeConditions.by_entity_type(self.entity_type.to_element().to_entity_type())
)
conditions.append(EntityScopeConditions.by_entity_type(self.entity_type.to_element()))

if self.entity_id is not None:
condition = self.entity_id.build_query_condition(
Expand Down
16 changes: 6 additions & 10 deletions src/ai/backend/manager/api/gql/rbac/types/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,16 +283,12 @@ def build_conditions(self) -> list[QueryCondition]:

if self.scope_type is not None:
conditions.append(
ScopedPermissionConditions.by_scope_type(
self.scope_type.to_element().to_scope_type()
)
ScopedPermissionConditions.by_scope_type(self.scope_type.to_element())
)

if self.entity_type is not None:
conditions.append(
ScopedPermissionConditions.by_entity_type(
self.entity_type.to_element().to_entity_type()
)
ScopedPermissionConditions.by_entity_type(self.entity_type.to_element())
)

return conditions
Expand Down Expand Up @@ -331,9 +327,9 @@ def to_creator(self) -> Creator[PermissionRow]:
return Creator(
spec=PermissionCreatorSpec(
role_id=self.role_id,
scope_type=self.scope_type.to_element().to_scope_type(),
scope_type=self.scope_type.to_element(),
scope_id=self.scope_id,
entity_type=self.entity_type.to_element().to_entity_type(),
entity_type=self.entity_type.to_element(),
operation=self.operation.to_internal(),
)
)
Expand All @@ -350,7 +346,7 @@ class UpdatePermissionInput:
def to_updater(self) -> Updater[PermissionRow]:
spec = PermissionUpdaterSpec(
scope_type=(
OptionalState.update(self.scope_type.to_element().to_scope_type())
OptionalState.update(self.scope_type.to_element())
if self.scope_type is not None
else OptionalState.nop()
),
Expand All @@ -360,7 +356,7 @@ def to_updater(self) -> Updater[PermissionRow]:
else OptionalState.nop()
),
entity_type=(
OptionalState.update(self.entity_type.to_element().to_entity_type())
OptionalState.update(self.entity_type.to_element())
if self.entity_type is not None
else OptionalState.nop()
),
Expand Down
12 changes: 6 additions & 6 deletions src/ai/backend/manager/api/rest/rbac/entity_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -28,9 +28,9 @@ 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.
Expand All @@ -45,9 +45,9 @@ def build_querier(
BatchQuerier with scope conditions and pagination settings
"""
conditions = [
EntityScopeConditions.by_scope_type(scope_type),
EntityScopeConditions.by_scope_type(scope_type.to_scope_type()),
EntityScopeConditions.by_scope_id(scope_id),
EntityScopeConditions.by_entity_type(entity_type),
EntityScopeConditions.by_entity_type(entity_type.to_entity_type()),
Comment on lines +48 to +50
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double conversion bug: EntityScopeConditions.by_scope_type() now accepts RBACElementType and internally calls .to_scope_type(). But here you're calling .to_scope_type() first, passing a ScopeType to a function that expects RBACElementType. This will cause .to_scope_type() to be called on a ScopeType object, which doesn't have that method. Same issue with .to_entity_type() on the next line. Pass the RBACElementType values directly without conversion.

Copilot uses AI. Check for mistakes.
]
pagination = OffsetPagination(limit=request.limit, offset=request.offset)
return BatchQuerier(conditions=conditions, orders=[], pagination=pagination)
Expand All @@ -62,6 +62,6 @@ def convert_to_dto(self, data: EntityData) -> EntityDTO:
EntityDTO for API response
"""
return EntityDTO(
entity_type=data.entity_type,
entity_type=data.entity_type.to_element(),
entity_id=data.entity_id,
)
14 changes: 11 additions & 3 deletions src/ai/backend/manager/api/rest/rbac/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ 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)
resp = GetScopeTypesResponse(items=action_result.element_types)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp)

async def search_scopes(
Expand All @@ -312,8 +312,16 @@ async def search_scopes(
raise NotEnoughPermission("Only superadmin can search scopes.")

scope_type = path.parsed.scope_type
if scope_type is None:
resp = SearchScopesResponse(
items=[],
pagination=PaginationInfo(
total=0, offset=body.parsed.offset, limit=body.parsed.limit
),
)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp)
querier = self._scope_adapter.build_querier(scope_type, body.parsed)
action = SearchScopesAction(scope_type=scope_type, querier=querier)
action = SearchScopesAction(element_type=scope_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],
Expand All @@ -338,7 +346,7 @@ 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)
resp = GetEntityTypesResponse(items=action_result.element_types)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=resp)

async def search_entities(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.entity_type is already RBACElementType (per the updated CreateObjectPermissionRequest), but RBACElementType does not have a to_element() method. Pass it directly without calling .to_element().

Suggested change
entity_type=request.entity_type.to_element(),
entity_type=request.entity_type,

Copilot uses AI. Check for mistakes.
entity_id=request.entity_id,
operation=request.operation,
status=request.status,
Expand Down
4 changes: 2 additions & 2 deletions src/ai/backend/manager/api/rest/rbac/permission_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment on lines +46 to +48
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.scope_type and request.entity_type are now RBACElementType (per the updated CreatePermissionRequest), but RBACElementType does not have a to_element() method — that method exists on ScopeType and EntityType. Since these are already RBACElementType, pass them directly without calling .to_element().

Copilot uses AI. Check for mistakes.
operation=request.operation,
)
)
Expand Down
26 changes: 17 additions & 9 deletions src/ai/backend/manager/api/rest/rbac/scope_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from ai.backend.common.data.permission.types import ScopeType
from ai.backend.common.data.permission.types import RBACElementType
from ai.backend.common.dto.manager.rbac.request import (
ScopeFilter,
ScopeOrder,
Expand Down Expand Up @@ -36,16 +36,22 @@
class ScopeAdapter(BaseFilterAdapter):
"""Adapter for converting scope requests to BatchQuerier objects."""

def build_querier(self, scope_type: ScopeType, request: SearchScopesRequest) -> BatchQuerier:
"""Build a BatchQuerier based on scope type."""
def build_querier(
self, scope_type: RBACElementType | None, request: SearchScopesRequest
) -> BatchQuerier:
"""Build a BatchQuerier based on scope type.

``scope_type`` is ``None`` for the GLOBAL scope, which has no
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to add GLOBAL as an enum value?
I think it doesn’t seem good to treat scope_type as GLOBAL when it is None or an empty string.

``RBACElementType`` equivalent.
"""
if scope_type is None:
return self._build_global_scope_querier(request)
match scope_type:
case ScopeType.GLOBAL:
return self._build_global_scope_querier(request)
case ScopeType.DOMAIN:
case RBACElementType.DOMAIN:
return self._build_domain_scope_querier(request)
case ScopeType.PROJECT:
case RBACElementType.PROJECT:
return self._build_project_scope_querier(request)
case ScopeType.USER:
case RBACElementType.USER:
return self._build_user_scope_querier(request)
case _:
raise NotImplementedError(
Expand Down Expand Up @@ -177,4 +183,6 @@ def convert_to_dto(self, data: ScopeData) -> ScopeDTO:
Returns:
ScopeDTO for API response
"""
return ScopeDTO(scope_type=data.id.scope_type, scope_id=data.id.scope_id, name=data.name)
return ScopeDTO(
scope_type=data.id.scope_type.to_element(), scope_id=data.id.scope_id, name=data.name
)
10 changes: 5 additions & 5 deletions src/ai/backend/manager/api/rest/vfolder/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
VFolderID,
)
from ai.backend.logging import BraceStyleAdapter
from ai.backend.manager.data.permission.types import ScopeType
from ai.backend.manager.data.permission.types import RBACElementType
from ai.backend.manager.dto.context import (
RequestCtx,
UserContext,
Expand Down Expand Up @@ -250,10 +250,10 @@ async def create(
unmanaged_path = params.unmanaged_path

if group_id_or_name is not None:
scope_type = ScopeType.PROJECT
scope_type = RBACElementType.PROJECT
scope_id = str(group_id_or_name)
else:
scope_type = ScopeType.USER
scope_type = RBACElementType.USER
scope_id = str(ctx.user_uuid)

try:
Expand Down Expand Up @@ -336,10 +336,10 @@ async def list_folders(
owner_user_uuid = user_scope.owner_uuid
group_id = params.group_id
if group_id is not None:
scope_type = ScopeType.PROJECT
scope_type = RBACElementType.PROJECT
scope_id = str(group_id)
else:
scope_type = ScopeType.USER
scope_type = RBACElementType.USER
scope_id = str(owner_user_uuid)
result = await self._vfolder.list_vfolder.wait_for_complete(
ListVFolderAction(
Expand Down
Loading
Loading