Skip to content

Commit be6e155

Browse files
fregataaclaude
andauthored
feat(BA-5797): add effective permissions resolver for entities (#11236)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd50181 commit be6e155

8 files changed

Lines changed: 1090 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 in the permission controller service and repository layers 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import uuid
4+
from collections.abc import Mapping
45
from dataclasses import dataclass, field
56
from datetime import datetime
67

@@ -94,6 +95,28 @@ class ScopeChainPermissionCheckInput:
9495
permission_entity_type: EntityType | None
9596

9697

98+
@dataclass(frozen=True)
99+
class EffectivePermissionsInput:
100+
"""Input for resolving effective permissions per entity for a given user.
101+
102+
Given a user, an element type, and a list of entity IDs, returns the
103+
set of permitted operations per entity by traversing the scope chain
104+
and evaluating all role/permission assignments.
105+
"""
106+
107+
user_id: uuid.UUID
108+
target_element_type: RBACElementType
109+
target_entity_ids: list[str]
110+
permission_entity_type: EntityType | None = None
111+
112+
113+
@dataclass(frozen=True)
114+
class EffectivePermissionsResult:
115+
"""Mapping from entity ID to the set of operations the user is authorized to perform."""
116+
117+
permissions: Mapping[str, set[OperationType]]
118+
119+
97120
@dataclass(frozen=True)
98121
class BulkPermissionCheckInput:
99122
user_id: uuid.UUID

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import uuid
3+
from collections import defaultdict
34
from collections.abc import Collection, Iterable, Sequence
45
from dataclasses import dataclass, field
56
from typing import Any, cast
@@ -40,6 +41,8 @@
4041
BulkRoleRevocationFailure,
4142
BulkRoleRevocationResultData,
4243
BulkUserRoleRevocationInput,
44+
EffectivePermissionsInput,
45+
EffectivePermissionsResult,
4346
ProjectRoleCount,
4447
RoleListResult,
4548
RolePermissionsUpdateInput,
@@ -1088,6 +1091,88 @@ async def check_bulk_permission_with_scope_chain(
10881091
)
10891092
return {eid: eid in granted for eid in data.target_entity_ids}
10901093

1094+
async def resolve_effective_permissions(
1095+
self,
1096+
data: EffectivePermissionsInput,
1097+
) -> EffectivePermissionsResult:
1098+
"""Resolve the effective permissions for a user across multiple entities.
1099+
1100+
Uses a single batched query that traverses the scope chain (AUTO edges)
1101+
and self-scope permissions to collect all operations the user can perform
1102+
on each entity.
1103+
1104+
Returns a mapping from entity ID to the set of permitted operations.
1105+
"""
1106+
if not data.target_entity_ids:
1107+
return EffectivePermissionsResult(permissions={})
1108+
1109+
association_entity_type = data.target_element_type.to_entity_type()
1110+
permission_entity_type = data.permission_entity_type or association_entity_type
1111+
target_scope_type = data.target_element_type.to_scope_type()
1112+
1113+
perm = PermissionRow.__table__
1114+
user_roles = UserRoleRow.__table__
1115+
roles = RoleRow.__table__
1116+
1117+
scope_chain_cte = self._build_scope_chain_cte(
1118+
association_entity_type, data.target_entity_ids
1119+
)
1120+
scope_chain_query = (
1121+
sa.select(
1122+
scope_chain_cte.c.entity_id,
1123+
perm.c.operation,
1124+
)
1125+
.select_from(
1126+
scope_chain_cte.join(
1127+
perm,
1128+
sa.and_(
1129+
perm.c.scope_type == scope_chain_cte.c.scope_type,
1130+
perm.c.scope_id == scope_chain_cte.c.scope_id,
1131+
),
1132+
)
1133+
.join(roles, roles.c.id == perm.c.role_id)
1134+
.join(user_roles, user_roles.c.role_id == roles.c.id)
1135+
)
1136+
.where(
1137+
sa.and_(
1138+
user_roles.c.user_id == data.user_id,
1139+
roles.c.status == RoleStatus.ACTIVE,
1140+
perm.c.entity_type == permission_entity_type,
1141+
)
1142+
)
1143+
)
1144+
1145+
self_scope_query = (
1146+
sa.select(
1147+
perm.c.scope_id.label("entity_id"),
1148+
perm.c.operation,
1149+
)
1150+
.select_from(
1151+
perm.join(roles, roles.c.id == perm.c.role_id).join(
1152+
user_roles, user_roles.c.role_id == roles.c.id
1153+
)
1154+
)
1155+
.where(
1156+
sa.and_(
1157+
user_roles.c.user_id == data.user_id,
1158+
roles.c.status == RoleStatus.ACTIVE,
1159+
perm.c.scope_type == target_scope_type,
1160+
perm.c.scope_id.in_(data.target_entity_ids),
1161+
perm.c.entity_type == permission_entity_type,
1162+
)
1163+
)
1164+
)
1165+
1166+
combined_query = sa.union_all(scope_chain_query, self_scope_query)
1167+
1168+
permissions: defaultdict[str, set[OperationType]] = defaultdict(set)
1169+
async with self._db.begin_readonly_session_read_committed() as db_session:
1170+
result = await db_session.execute(combined_query)
1171+
for row in result:
1172+
permissions[row.entity_id].add(OperationType(row.operation))
1173+
1174+
return EffectivePermissionsResult(permissions=permissions)
1175+
10911176
async def bulk_assign_role(
10921177
self, bulk_creator: BulkCreator[UserRoleRow]
10931178
) -> 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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Mapping
4+
from dataclasses import dataclass, field
5+
from typing import override
6+
from uuid import UUID
7+
8+
from ai.backend.common.data.permission.types import EntityType, OperationType, RBACElementType
9+
from ai.backend.manager.actions.action import BaseAction, BaseActionResult
10+
from ai.backend.manager.actions.types import ActionOperationType
11+
12+
13+
@dataclass
14+
class ResolveEffectivePermissionsAction(BaseAction):
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 entity_type(cls) -> EntityType:
34+
return EntityType.PERMISSION
35+
36+
@override
37+
@classmethod
38+
def operation_type(cls) -> ActionOperationType:
39+
return ActionOperationType.GET
40+
41+
42+
@dataclass
43+
class ResolveEffectivePermissionsActionResult(BaseActionResult):
44+
"""Result containing the effective permissions per entity."""
45+
46+
permissions: Mapping[str, set[OperationType]] = field(default_factory=dict)
47+
48+
@override
49+
def entity_id(self) -> str | None:
50+
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)