Skip to content

Commit 4328489

Browse files
fregataaclaude
andcommitted
feat(BA-5797): add effective permissions resolver for entities
Add resolve_effective_permissions across the full stack (data types → DB source → repository → service/action) to answer "what operations can user X perform on entities [A, B, C]?" Uses a single batched query with the existing scope chain CTE, eliminating N+1 by processing all entity IDs in one DB roundtrip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 29bfd3c commit 4328489

7 files changed

Lines changed: 195 additions & 0 deletions

File tree

changes/11236.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an effective permissions resolver API that returns all permitted operations a user can perform on given entities by traversing the RBAC scope chain.

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,28 @@ 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, an element type, and a list of entity IDs, returns the
102+
set of permitted operations per entity by traversing the scope chain
103+
and evaluating all role/permission assignments.
104+
"""
105+
106+
user_id: uuid.UUID
107+
target_element_type: RBACElementType
108+
target_entity_ids: list[str]
109+
permission_entity_type: EntityType | None = None
110+
111+
112+
@dataclass(frozen=True)
113+
class EffectivePermissionsResult:
114+
"""Mapping from entity ID to the set of operations the user is authorized to perform."""
115+
116+
permissions: dict[str, set[OperationType]]
117+
118+
97119
@dataclass(frozen=True)
98120
class BulkPermissionCheckInput:
99121
user_id: uuid.UUID

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
BulkRoleRevocationFailure,
4141
BulkRoleRevocationResultData,
4242
BulkUserRoleRevocationInput,
43+
EffectivePermissionsInput,
44+
EffectivePermissionsResult,
4345
ProjectRoleCount,
4446
RoleListResult,
4547
RolePermissionsUpdateInput,
@@ -1088,6 +1090,88 @@ async def check_bulk_permission_with_scope_chain(
10881090
)
10891091
return {eid: eid in granted for eid in data.target_entity_ids}
10901092

1093+
async def resolve_effective_permissions(
1094+
self,
1095+
data: EffectivePermissionsInput,
1096+
) -> EffectivePermissionsResult:
1097+
"""Resolve the effective permissions for a user across multiple entities.
1098+
1099+
Uses a single batched query that traverses the scope chain (AUTO edges)
1100+
and self-scope permissions to collect all operations the user can perform
1101+
on each entity.
1102+
1103+
Returns a mapping from entity ID to the set of permitted operations.
1104+
"""
1105+
if not data.target_entity_ids:
1106+
return EffectivePermissionsResult(permissions={})
1107+
1108+
association_entity_type = data.target_element_type.to_entity_type()
1109+
permission_entity_type = data.permission_entity_type or association_entity_type
1110+
target_scope_type = data.target_element_type.to_scope_type()
1111+
1112+
perm = PermissionRow.__table__
1113+
user_roles = UserRoleRow.__table__
1114+
roles = RoleRow.__table__
1115+
1116+
scope_chain_cte = self._build_scope_chain_cte(
1117+
association_entity_type, data.target_entity_ids
1118+
)
1119+
scope_chain_query = (
1120+
sa.select(
1121+
scope_chain_cte.c.entity_id,
1122+
perm.c.operation,
1123+
)
1124+
.select_from(
1125+
scope_chain_cte.join(
1126+
perm,
1127+
sa.and_(
1128+
perm.c.scope_type == scope_chain_cte.c.scope_type,
1129+
perm.c.scope_id == scope_chain_cte.c.scope_id,
1130+
),
1131+
)
1132+
.join(roles, roles.c.id == perm.c.role_id)
1133+
.join(user_roles, user_roles.c.role_id == roles.c.id)
1134+
)
1135+
.where(
1136+
sa.and_(
1137+
user_roles.c.user_id == data.user_id,
1138+
roles.c.status == RoleStatus.ACTIVE,
1139+
perm.c.entity_type == permission_entity_type,
1140+
)
1141+
)
1142+
)
1143+
1144+
self_scope_query = (
1145+
sa.select(
1146+
perm.c.scope_id.label("entity_id"),
1147+
perm.c.operation,
1148+
)
1149+
.select_from(
1150+
perm.join(roles, roles.c.id == perm.c.role_id).join(
1151+
user_roles, user_roles.c.role_id == roles.c.id
1152+
)
1153+
)
1154+
.where(
1155+
sa.and_(
1156+
user_roles.c.user_id == data.user_id,
1157+
roles.c.status == RoleStatus.ACTIVE,
1158+
perm.c.scope_type == target_scope_type,
1159+
perm.c.scope_id.in_(data.target_entity_ids),
1160+
perm.c.entity_type == permission_entity_type,
1161+
)
1162+
)
1163+
)
1164+
1165+
combined_query = sa.union_all(scope_chain_query, self_scope_query)
1166+
1167+
permissions: dict[str, set[OperationType]] = {eid: set() for eid in data.target_entity_ids}
1168+
async with self._db.begin_readonly_session_read_committed() as db_session:
1169+
result = await db_session.execute(combined_query)
1170+
for row in result:
1171+
permissions[row.entity_id].add(OperationType(row.operation))
1172+
1173+
return EffectivePermissionsResult(permissions=permissions)
1174+
10911175
async def bulk_assign_role(
10921176
self, bulk_creator: BulkCreator[UserRoleRow]
10931177
) -> 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
@@ -31,6 +31,8 @@
3131
BulkRoleAssignmentResultData,
3232
BulkRoleRevocationResultData,
3333
BulkUserRoleRevocationInput,
34+
EffectivePermissionsInput,
35+
EffectivePermissionsResult,
3436
RoleData,
3537
RoleDetailData,
3638
RoleListResult,
@@ -363,6 +365,18 @@ async def check_bulk_permission_with_scope_chain(
363365
"""
364366
return await self._db_source.check_bulk_permission_with_scope_chain(data)
365367

368+
@permission_controller_repository_resilience.apply()
369+
async def resolve_effective_permissions(
370+
self,
371+
data: EffectivePermissionsInput,
372+
) -> EffectivePermissionsResult:
373+
"""Resolve the set of permitted operations per entity for a given user.
374+
375+
For each target entity, traverses the scope chain (AUTO edges) and
376+
self-scope permissions to collect all operations the user can perform.
377+
"""
378+
return await self._db_source.resolve_effective_permissions(data)
379+
366380
# -- role invitation --
367381

368382
@permission_controller_repository_resilience.apply()

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, RBACElementType
8+
from ai.backend.manager.actions.action import BaseActionResult
9+
from ai.backend.manager.actions.types import ActionOperationType
10+
from ai.backend.manager.services.permission_contoller.actions.base import RoleAction
11+
12+
13+
@dataclass
14+
class ResolveEffectivePermissionsAction(RoleAction):
15+
"""Action to resolve effective permissions per entity for a given user.
16+
17+
Given a user ID, an element type, and a list of entity IDs, returns the
18+
set of permitted operations per entity by traversing the scope chain and
19+
evaluating all role/permission assignments.
20+
"""
21+
22+
user_id: UUID
23+
target_element_type: RBACElementType
24+
target_entity_ids: list[str]
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
RejectRoleInvitationAction,
1818
)
1919
from ai.backend.manager.data.permission.role import (
20+
EffectivePermissionsInput,
2021
UserRoleRevocationData,
2122
)
2223
from ai.backend.manager.data.role_invitation.types import RoleInvitationData
@@ -74,6 +75,10 @@
7475
PurgeRoleAction,
7576
PurgeRoleActionResult,
7677
)
78+
from ai.backend.manager.services.permission_contoller.actions.resolve_effective_permissions import (
79+
ResolveEffectivePermissionsAction,
80+
ResolveEffectivePermissionsActionResult,
81+
)
7782
from ai.backend.manager.services.permission_contoller.actions.revoke_role import (
7883
RevokeRoleAction,
7984
RevokeRoleActionResult,
@@ -360,6 +365,24 @@ async def get_permission_matrix(
360365
actions[action_cls.action_name()] = perm
361366
return GetPermissionMatrixActionResult(matrix=result)
362367

368+
async def resolve_effective_permissions(
369+
self, action: ResolveEffectivePermissionsAction
370+
) -> ResolveEffectivePermissionsActionResult:
371+
"""Resolve the set of permitted operations per entity for a given user.
372+
373+
Traverses the scope chain and evaluates all role/permission assignments
374+
to return all operations the user is authorized to perform on each entity.
375+
"""
376+
result = await self._repository.resolve_effective_permissions(
377+
EffectivePermissionsInput(
378+
user_id=action.user_id,
379+
target_element_type=action.target_element_type,
380+
target_entity_ids=action.target_entity_ids,
381+
permission_entity_type=action.permission_entity_type,
382+
)
383+
)
384+
return ResolveEffectivePermissionsActionResult(permissions=result.permissions)
385+
363386
async def create_role_invitation_by_email(
364387
self, action: CreateRoleInvitationByEmailAction
365388
) -> CreateRoleInvitationResult:

0 commit comments

Comments
 (0)