Skip to content

Commit a34eac6

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 d133bff commit a34eac6

7 files changed

Lines changed: 187 additions & 100 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: 9 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,6 @@
1010
from ai.backend.common.resilience.policies.metrics import MetricArgs, MetricPolicy
1111
from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy
1212
from ai.backend.common.resilience.resilience import Resilience
13-
from ai.backend.manager.actions.action.rbac_role_invitation import (
14-
AcceptRoleInvitationAction,
15-
CancelRoleInvitationAction,
16-
CreateRoleInvitationByEmailAction,
17-
CreateRoleInvitationResult,
18-
RejectRoleInvitationAction,
19-
)
2013
from ai.backend.manager.data.permission.entity import ElementAssociationListResult, EntityListResult
2114
from ai.backend.manager.data.permission.id import ObjectId
2215
from ai.backend.manager.data.permission.permission import (
@@ -26,11 +19,12 @@
2619
from ai.backend.manager.data.permission.role import (
2720
AssignedUserListResult,
2821
BatchEntityPermissionCheckInput,
29-
BulkPermissionCheckInput,
3022
BulkRoleAssignmentFailure,
3123
BulkRoleAssignmentResultData,
3224
BulkRoleRevocationResultData,
3325
BulkUserRoleRevocationInput,
26+
EffectivePermissionsInput,
27+
EffectivePermissionsResult,
3428
RoleData,
3529
RoleDetailData,
3630
RoleListResult,
@@ -46,7 +40,6 @@
4640
from ai.backend.manager.data.permission.types import (
4741
ScopeListResult,
4842
)
49-
from ai.backend.manager.data.role_invitation.types import RoleInvitationData
5043
from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow
5144
from ai.backend.manager.models.rbac_models.role import RoleRow
5245
from ai.backend.manager.models.rbac_models.user_role import UserRoleRow
@@ -60,11 +53,6 @@
6053
PermissionSearchScope,
6154
ScopedRoleSearchScope,
6255
)
63-
from ai.backend.manager.repositories.role_invitation.types import (
64-
InviteeSearchScope,
65-
RoleInvitationSearchResult,
66-
RoleInvitationSearchScope,
67-
)
6856

6957
from .db_source.db_source import CreateRoleInput, PermissionDBSource
7058

@@ -352,59 +340,13 @@ async def check_permission_with_scope_chain(
352340
return await self._db_source.check_permission_with_scope_chain(data)
353341

354342
@permission_controller_repository_resilience.apply()
355-
async def check_bulk_permission_with_scope_chain(
343+
async def resolve_effective_permissions(
356344
self,
357-
data: BulkPermissionCheckInput,
358-
) -> dict[str, bool]:
359-
"""Batch permission check that traverses the scope chain via AUTO edges.
345+
data: EffectivePermissionsInput,
346+
) -> EffectivePermissionsResult:
347+
"""Resolve the set of permitted operations per entity for a given user.
360348
361-
Same semantics as check_permission_with_scope_chain but for multiple
362-
entities of the same RBACElementType in a single query.
349+
For each target entity, traverses the scope chain (AUTO edges) and
350+
self-scope permissions to collect all operations the user can perform.
363351
"""
364-
return await self._db_source.check_bulk_permission_with_scope_chain(data)
365-
366-
# -- role invitation --
367-
368-
@permission_controller_repository_resilience.apply()
369-
async def create_invitation_by_email(
370-
self,
371-
action: CreateRoleInvitationByEmailAction,
372-
) -> CreateRoleInvitationResult:
373-
return await self._db_source.create_invitation_by_email(action)
374-
375-
@permission_controller_repository_resilience.apply()
376-
async def search_invitations_by_invitee(
377-
self,
378-
querier: BatchQuerier,
379-
scope: InviteeSearchScope,
380-
) -> RoleInvitationSearchResult:
381-
return await self._db_source.search_invitations_by_invitee(querier, scope)
382-
383-
@permission_controller_repository_resilience.apply()
384-
async def search_invitations_by_role(
385-
self,
386-
querier: BatchQuerier,
387-
scope: RoleInvitationSearchScope,
388-
) -> RoleInvitationSearchResult:
389-
return await self._db_source.search_invitations_by_role(querier, scope)
390-
391-
@permission_controller_repository_resilience.apply()
392-
async def accept_invitation(
393-
self,
394-
action: AcceptRoleInvitationAction,
395-
) -> RoleInvitationData:
396-
return await self._db_source.accept_invitation(action)
397-
398-
@permission_controller_repository_resilience.apply()
399-
async def reject_invitation(
400-
self,
401-
action: RejectRoleInvitationAction,
402-
) -> RoleInvitationData:
403-
return await self._db_source.reject_invitation(action)
404-
405-
@permission_controller_repository_resilience.apply()
406-
async def cancel_invitation(
407-
self,
408-
action: CancelRoleInvitationAction,
409-
) -> RoleInvitationData:
410-
return await self._db_source.cancel_invitation(action)
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, 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: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,10 @@
99
RBACActionName,
1010
RBACRequiredPermission,
1111
)
12-
from ai.backend.manager.actions.action.rbac_role_invitation import (
13-
AcceptRoleInvitationAction,
14-
CancelRoleInvitationAction,
15-
CreateRoleInvitationByEmailAction,
16-
CreateRoleInvitationResult,
17-
RejectRoleInvitationAction,
18-
)
1912
from ai.backend.manager.data.permission.role import (
13+
EffectivePermissionsInput,
2014
UserRoleRevocationData,
2115
)
22-
from ai.backend.manager.data.role_invitation.types import RoleInvitationData
2316
from ai.backend.manager.repositories.group.repository import GroupRepository
2417
from ai.backend.manager.repositories.permission_controller.creators import UserRoleCreatorSpec
2518
from ai.backend.manager.repositories.permission_controller.db_source.db_source import (
@@ -74,6 +67,10 @@
7467
PurgeRoleAction,
7568
PurgeRoleActionResult,
7669
)
70+
from ai.backend.manager.services.permission_contoller.actions.resolve_effective_permissions import (
71+
ResolveEffectivePermissionsAction,
72+
ResolveEffectivePermissionsActionResult,
73+
)
7774
from ai.backend.manager.services.permission_contoller.actions.revoke_role import (
7875
RevokeRoleAction,
7976
RevokeRoleActionResult,
@@ -360,30 +357,20 @@ async def get_permission_matrix(
360357
actions[action_cls.action_name()] = perm
361358
return GetPermissionMatrixActionResult(matrix=result)
362359

363-
async def create_role_invitation_by_email(
364-
self, action: CreateRoleInvitationByEmailAction
365-
) -> CreateRoleInvitationResult:
366-
"""Create role invitations by resolving invitee emails."""
367-
return await self._repository.create_invitation_by_email(action)
368-
369-
async def accept_role_invitation(
370-
self, action: AcceptRoleInvitationAction
371-
) -> RoleInvitationData:
372-
"""Accept a PENDING invitation and assign the role.
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.
373364
374-
State transition and role assignment happen atomically
375-
in a single DB session within the repository.
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.
376367
"""
377-
return await self._repository.accept_invitation(action)
378-
379-
async def reject_role_invitation(
380-
self, action: RejectRoleInvitationAction
381-
) -> RoleInvitationData:
382-
"""Reject a PENDING invitation."""
383-
return await self._repository.reject_invitation(action)
384-
385-
async def cancel_role_invitation(
386-
self, action: CancelRoleInvitationAction
387-
) -> RoleInvitationData:
388-
"""Cancel a PENDING invitation."""
389-
return await self._repository.cancel_invitation(action)
368+
result = await self._repository.resolve_effective_permissions(
369+
EffectivePermissionsInput(
370+
user_id=action.user_id,
371+
target_element_type=action.target_element_type,
372+
target_entity_ids=action.target_entity_ids,
373+
permission_entity_type=action.permission_entity_type,
374+
)
375+
)
376+
return ResolveEffectivePermissionsActionResult(permissions=result.permissions)

0 commit comments

Comments
 (0)