Skip to content

Commit 33d1652

Browse files
fregataaclaude
andcommitted
feat(BA-5797): add effective permissions resolver for entities
Implement repository, service, and action layers to resolve all permitted operations a user can perform on given entities by traversing the scope chain and evaluating role/permission assignments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf2d491 commit 33d1652

6 files changed

Lines changed: 228 additions & 1 deletion

File tree

src/ai/backend/manager/data/permission/role.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ class ScopeChainPermissionCheckInput:
9494
permission_entity_type: EntityType | None
9595

9696

97+
@dataclass(frozen=True)
98+
class EffectivePermissionsInput:
99+
"""Input for resolving effective permissions per entity for a given user.
100+
101+
Given a user and a list of entity references (same RBACElementType),
102+
returns the set of permitted operations per entity by traversing the
103+
scope chain and evaluating all role/permission assignments.
104+
"""
105+
106+
user_id: uuid.UUID
107+
target_element_refs: list[RBACElementRef]
108+
permission_entity_type: EntityType | None = None
109+
110+
111+
@dataclass(frozen=True)
112+
class EffectivePermissionsResult:
113+
"""Mapping from entity ID to the set of operations the user is authorized to perform."""
114+
115+
permissions: dict[str, set[OperationType]]
116+
117+
97118
@dataclass(frozen=True)
98119
class UserRoleAssignmentInput:
99120
"""

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
BulkRoleRevocationFailure,
3333
BulkRoleRevocationResultData,
3434
BulkUserRoleRevocationInput,
35+
EffectivePermissionsInput,
36+
EffectivePermissionsResult,
3537
ProjectRoleCount,
3638
RoleListResult,
3739
RolePermissionsUpdateInput,
@@ -1121,6 +1123,121 @@ async def check_permission_with_scope_chain(
11211123
result = await db_session.scalar(combined_query)
11221124
return result or False
11231125

1126+
def _build_scope_chain_operations_query(
1127+
self,
1128+
user_id: uuid.UUID,
1129+
target_element_ref: RBACElementRef,
1130+
target_entity_type: EntityType,
1131+
) -> sa.sql.Select[Any]:
1132+
"""Build a query that returns all permitted operations via CTE scope chain traversal."""
1133+
permissions = PermissionRow.__table__
1134+
user_roles = UserRoleRow.__table__
1135+
roles = RoleRow.__table__
1136+
1137+
scope_chain_cte = self._build_scope_chain_cte(target_element_ref)
1138+
return (
1139+
sa.select(permissions.c.operation)
1140+
.select_from(
1141+
scope_chain_cte.join(
1142+
permissions,
1143+
sa.and_(
1144+
permissions.c.scope_type == scope_chain_cte.c.scope_type,
1145+
permissions.c.scope_id == scope_chain_cte.c.scope_id,
1146+
),
1147+
)
1148+
.join(
1149+
roles,
1150+
roles.c.id == permissions.c.role_id,
1151+
)
1152+
.join(
1153+
user_roles,
1154+
user_roles.c.role_id == roles.c.id,
1155+
)
1156+
)
1157+
.where(
1158+
sa.and_(
1159+
user_roles.c.user_id == user_id,
1160+
roles.c.status == RoleStatus.ACTIVE,
1161+
permissions.c.entity_type == target_entity_type,
1162+
)
1163+
)
1164+
.distinct()
1165+
)
1166+
1167+
def _build_self_scope_operations_query(
1168+
self,
1169+
user_id: uuid.UUID,
1170+
target_element_ref: RBACElementRef,
1171+
target_entity_type: EntityType,
1172+
target_scope_type: ScopeType,
1173+
) -> sa.sql.Select[Any]:
1174+
"""Build a query that returns all permitted operations scoped to the target entity itself."""
1175+
permissions = PermissionRow.__table__
1176+
user_roles = UserRoleRow.__table__
1177+
roles = RoleRow.__table__
1178+
1179+
return (
1180+
sa.select(permissions.c.operation)
1181+
.select_from(
1182+
permissions.join(
1183+
roles,
1184+
roles.c.id == permissions.c.role_id,
1185+
).join(
1186+
user_roles,
1187+
user_roles.c.role_id == roles.c.id,
1188+
)
1189+
)
1190+
.where(
1191+
sa.and_(
1192+
user_roles.c.user_id == user_id,
1193+
roles.c.status == RoleStatus.ACTIVE,
1194+
permissions.c.scope_type == target_scope_type,
1195+
permissions.c.scope_id == target_element_ref.element_id,
1196+
permissions.c.entity_type == target_entity_type,
1197+
)
1198+
)
1199+
.distinct()
1200+
)
1201+
1202+
async def resolve_effective_permissions(
1203+
self,
1204+
data: EffectivePermissionsInput,
1205+
) -> EffectivePermissionsResult:
1206+
"""Resolve the effective permissions for a user across multiple entities.
1207+
1208+
For each target entity, traverses the scope chain (AUTO edges only) and
1209+
self-scope permissions to collect ALL operations the user can perform.
1210+
1211+
Returns a mapping from entity ID to the set of permitted operations.
1212+
"""
1213+
permissions: dict[str, set[OperationType]] = {}
1214+
1215+
async with self._db.begin_readonly_session_read_committed() as db_session:
1216+
for ref in data.target_element_refs:
1217+
target_entity_type = (
1218+
data.permission_entity_type or ref.element_type.to_entity_type()
1219+
)
1220+
target_scope_type = ref.element_type.to_scope_type()
1221+
1222+
scope_chain_query = self._build_scope_chain_operations_query(
1223+
data.user_id,
1224+
ref,
1225+
target_entity_type,
1226+
)
1227+
self_scope_query = self._build_self_scope_operations_query(
1228+
data.user_id,
1229+
ref,
1230+
target_entity_type,
1231+
target_scope_type,
1232+
)
1233+
combined_query = sa.union(scope_chain_query, self_scope_query)
1234+
1235+
result = await db_session.execute(combined_query)
1236+
operations = {OperationType(row[0]) for row in result}
1237+
permissions[ref.element_id] = operations
1238+
1239+
return EffectivePermissionsResult(permissions=permissions)
1240+
11241241
async def bulk_assign_role(
11251242
self, bulk_creator: BulkCreator[UserRoleRow]
11261243
) -> BulkCreatorResultWithFailures[UserRoleRow]:

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
BulkRoleAssignmentResultData,
2424
BulkRoleRevocationResultData,
2525
BulkUserRoleRevocationInput,
26+
EffectivePermissionsInput,
27+
EffectivePermissionsResult,
2628
RoleData,
2729
RoleDetailData,
2830
RoleListResult,
@@ -336,3 +338,15 @@ async def check_permission_with_scope_chain(
336338
scope. REF edges are not traversed.
337339
"""
338340
return await self._db_source.check_permission_with_scope_chain(data)
341+
342+
@permission_controller_repository_resilience.apply()
343+
async def resolve_effective_permissions(
344+
self,
345+
data: EffectivePermissionsInput,
346+
) -> EffectivePermissionsResult:
347+
"""Resolve the set of permitted operations per entity for a given user.
348+
349+
For each target entity, traverses the scope chain (AUTO edges) and
350+
self-scope permissions to collect all operations the user can perform.
351+
"""
352+
return await self._db_source.resolve_effective_permissions(data)

src/ai/backend/manager/services/permission_contoller/actions/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
from .get_permission_matrix import GetPermissionMatrixAction, GetPermissionMatrixActionResult
88
from .get_role_detail import GetRoleDetailAction, GetRoleDetailActionResult
99
from .purge_role import PurgeRoleAction, PurgeRoleActionResult
10+
from .resolve_effective_permissions import (
11+
ResolveEffectivePermissionsAction,
12+
ResolveEffectivePermissionsActionResult,
13+
)
1014
from .revoke_role import RevokeRoleAction, RevokeRoleActionResult
1115
from .search_permissions import (
1216
SearchPermissionsAction,
@@ -47,6 +51,8 @@
4751
"GetRoleDetailActionResult",
4852
"PurgeRoleAction",
4953
"PurgeRoleActionResult",
54+
"ResolveEffectivePermissionsAction",
55+
"ResolveEffectivePermissionsActionResult",
5056
"RevokeRoleAction",
5157
"RevokeRoleActionResult",
5258
"SearchRolesAction",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from typing import override
5+
from uuid import UUID
6+
7+
from ai.backend.common.data.permission.types import EntityType, OperationType
8+
from ai.backend.manager.actions.action import BaseActionResult
9+
from ai.backend.manager.actions.types import ActionOperationType
10+
from ai.backend.manager.data.permission.types import RBACElementRef
11+
from ai.backend.manager.services.permission_contoller.actions.base import RoleAction
12+
13+
14+
@dataclass
15+
class ResolveEffectivePermissionsAction(RoleAction):
16+
"""Action to resolve effective permissions per entity for a given user.
17+
18+
Given a user ID and a list of entity references (same RBACElementType),
19+
returns the set of permitted operations per entity by traversing the
20+
scope chain and evaluating all role/permission assignments.
21+
"""
22+
23+
user_id: UUID
24+
target_element_refs: list[RBACElementRef]
25+
permission_entity_type: EntityType | None = None
26+
27+
@override
28+
def entity_id(self) -> str | None:
29+
return str(self.user_id)
30+
31+
@override
32+
@classmethod
33+
def operation_type(cls) -> ActionOperationType:
34+
return ActionOperationType.GET
35+
36+
37+
@dataclass
38+
class ResolveEffectivePermissionsActionResult(BaseActionResult):
39+
"""Result containing the effective permissions per entity."""
40+
41+
permissions: dict[str, set[OperationType]] = field(default_factory=dict)
42+
43+
@override
44+
def entity_id(self) -> str | None:
45+
return None

src/ai/backend/manager/services/permission_contoller/service.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
RBACActionName,
1010
RBACRequiredPermission,
1111
)
12-
from ai.backend.manager.data.permission.role import UserRoleRevocationData
12+
from ai.backend.manager.data.permission.role import (
13+
EffectivePermissionsInput,
14+
UserRoleRevocationData,
15+
)
1316
from ai.backend.manager.repositories.group.repository import GroupRepository
1417
from ai.backend.manager.repositories.permission_controller.creators import UserRoleCreatorSpec
1518
from ai.backend.manager.repositories.permission_controller.db_source.db_source import (
@@ -64,6 +67,10 @@
6467
PurgeRoleAction,
6568
PurgeRoleActionResult,
6669
)
70+
from ai.backend.manager.services.permission_contoller.actions.resolve_effective_permissions import (
71+
ResolveEffectivePermissionsAction,
72+
ResolveEffectivePermissionsActionResult,
73+
)
6774
from ai.backend.manager.services.permission_contoller.actions.revoke_role import (
6875
RevokeRoleAction,
6976
RevokeRoleActionResult,
@@ -349,3 +356,20 @@ async def get_permission_matrix(
349356
actions = entity_map.setdefault(perm.element_type, {})
350357
actions[action_cls.action_name()] = perm
351358
return GetPermissionMatrixActionResult(matrix=result)
359+
360+
async def resolve_effective_permissions(
361+
self, action: ResolveEffectivePermissionsAction
362+
) -> ResolveEffectivePermissionsActionResult:
363+
"""Resolve the set of permitted operations per entity for a given user.
364+
365+
Traverses the scope chain and evaluates all role/permission assignments
366+
to return all operations the user is authorized to perform on each entity.
367+
"""
368+
result = await self._repository.resolve_effective_permissions(
369+
EffectivePermissionsInput(
370+
user_id=action.user_id,
371+
target_element_refs=action.target_element_refs,
372+
permission_entity_type=action.permission_entity_type,
373+
)
374+
)
375+
return ResolveEffectivePermissionsActionResult(permissions=result.permissions)

0 commit comments

Comments
 (0)