Skip to content

Commit d4b73c6

Browse files
fregataaclaude
andcommitted
feat(BA-5769): add my-role SDK, CLI, and v2 component tests
- Add my_search_role_assignments adapter method with current_user() scope - Add REST v2 endpoint POST /assignments/my/search (auth_required) - Add my_search_assignments SDK method in V2RBACClient - Add CLI command: ./bai my role search - Add component tests for assign/revoke/my-search via v2 REST SDK - Rename AdminSearchRoleAssignmentsGQLInput -> AdminSearchRoleAssignmentsInput - Rename admin_search_role_assignments_gql -> admin_search_role_assignments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a94de23 commit d4b73c6

13 files changed

Lines changed: 455 additions & 24 deletions

File tree

src/ai/backend/client/cli/v2/my/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ def keypair() -> None:
2121
"""My keypair commands."""
2222

2323

24+
@my.group(cls=LazyGroup, import_name="ai.backend.client.cli.v2.my.role:role")
25+
def role() -> None:
26+
"""My role commands."""
27+
28+
2429
@my.group(
2530
cls=LazyGroup,
2631
import_name="ai.backend.client.cli.v2.my.login_history:login_history",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""CLI commands for self-service role operations."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
7+
import click
8+
9+
from ai.backend.client.cli.v2.helpers import (
10+
create_v2_registry,
11+
load_v2_config,
12+
parse_order_options,
13+
print_result,
14+
)
15+
16+
17+
@click.group()
18+
def role() -> None:
19+
"""My role commands."""
20+
21+
22+
@role.command()
23+
@click.option("--limit", default=None, type=int, help="Maximum number of results to return.")
24+
@click.option("--offset", default=None, type=int, help="Number of results to skip.")
25+
@click.option("--first", default=None, type=int, help="Cursor-based: return first N items.")
26+
@click.option("--after", default=None, type=str, help="Cursor-based: return items after cursor.")
27+
@click.option("--last", default=None, type=int, help="Cursor-based: return last N items.")
28+
@click.option("--before", default=None, type=str, help="Cursor-based: return items before cursor.")
29+
@click.option("--role-id", type=str, default=None, help="Filter by role UUID.")
30+
@click.option(
31+
"--order-by",
32+
multiple=True,
33+
help="Order by field:direction (e.g., granted_at:desc).",
34+
)
35+
def search(
36+
limit: int | None,
37+
offset: int | None,
38+
first: int | None,
39+
after: str | None,
40+
last: int | None,
41+
before: str | None,
42+
role_id: str | None,
43+
order_by: tuple[str, ...],
44+
) -> None:
45+
"""Search my role assignments."""
46+
from uuid import UUID
47+
48+
from ai.backend.common.dto.manager.v2.rbac.request import (
49+
AdminSearchRoleAssignmentsInput,
50+
RoleAssignmentFilter,
51+
RoleAssignmentOrderBy,
52+
)
53+
from ai.backend.common.dto.manager.v2.rbac.types import RoleAssignmentOrderField
54+
55+
filter_dto: RoleAssignmentFilter | None = None
56+
if role_id is not None:
57+
filter_dto = RoleAssignmentFilter(
58+
role_id=UUID(role_id),
59+
)
60+
61+
orders = (
62+
parse_order_options(order_by, RoleAssignmentOrderField, RoleAssignmentOrderBy)
63+
if order_by
64+
else None
65+
)
66+
67+
async def _run() -> None:
68+
registry = await create_v2_registry(load_v2_config())
69+
try:
70+
request = AdminSearchRoleAssignmentsInput(
71+
filter=filter_dto,
72+
order=orders,
73+
first=first,
74+
after=after,
75+
last=last,
76+
before=before,
77+
limit=limit,
78+
offset=offset,
79+
)
80+
result = await registry.rbac.my_search_assignments(request)
81+
print_result(result)
82+
finally:
83+
await registry.close()
84+
85+
asyncio.run(_run())

src/ai/backend/client/cli/v2/rbac/assignment.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def search(
4242
"""Search role assignments."""
4343
from ai.backend.common.dto.manager.query import StringFilter
4444
from ai.backend.common.dto.manager.v2.rbac.request import (
45-
AdminSearchRoleAssignmentsGQLInput,
45+
AdminSearchRoleAssignmentsInput,
4646
RoleAssignmentFilter,
4747
RoleAssignmentOrderBy,
4848
)
@@ -70,7 +70,7 @@ async def _run() -> None:
7070
registry = await create_v2_registry(load_v2_config())
7171
try:
7272
result = await registry.rbac.search_assignments(
73-
AdminSearchRoleAssignmentsGQLInput(
73+
AdminSearchRoleAssignmentsInput(
7474
filter=filter_dto,
7575
order=orders,
7676
limit=limit,

src/ai/backend/client/v2/domains_v2/rbac.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ai.backend.common.dto.manager.v2.rbac.request import (
99
AdminSearchEntitiesGQLInput,
1010
AdminSearchPermissionsGQLInput,
11-
AdminSearchRoleAssignmentsGQLInput,
11+
AdminSearchRoleAssignmentsInput,
1212
AssignRoleInput,
1313
BulkAssignRoleInput,
1414
BulkRevokeRoleInput,
@@ -172,7 +172,7 @@ async def revoke_role(self, request: RevokeRoleInput) -> RoleAssignmentNode:
172172
)
173173

174174
async def search_assignments(
175-
self, request: AdminSearchRoleAssignmentsGQLInput
175+
self, request: AdminSearchRoleAssignmentsInput
176176
) -> AdminSearchRoleAssignmentsPayload:
177177
"""Search role assignments with filters, orders, and pagination."""
178178
return await self._client.typed_request(
@@ -182,6 +182,17 @@ async def search_assignments(
182182
response_model=AdminSearchRoleAssignmentsPayload,
183183
)
184184

185+
async def my_search_assignments(
186+
self, request: AdminSearchRoleAssignmentsInput
187+
) -> AdminSearchRoleAssignmentsPayload:
188+
"""Search role assignments for the current authenticated user."""
189+
return await self._client.typed_request(
190+
"POST",
191+
f"{_PATH}/assignments/my/search",
192+
request=request,
193+
response_model=AdminSearchRoleAssignmentsPayload,
194+
)
195+
185196
async def bulk_assign_role(self, request: BulkAssignRoleInput) -> BulkAssignRoleResultPayload:
186197
"""Bulk-assign a role to multiple users."""
187198
return await self._client.typed_request(

src/ai/backend/common/dto/manager/v2/rbac/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ai.backend.common.dto.manager.v2.rbac.request import (
66
AdminSearchEntitiesGQLInput,
77
AdminSearchPermissionsGQLInput,
8-
AdminSearchRoleAssignmentsGQLInput,
8+
AdminSearchRoleAssignmentsInput,
99
AssignRoleInput,
1010
BulkAssignRoleInput,
1111
BulkRevokeRoleInput,
@@ -93,7 +93,7 @@
9393
# Input models (request)
9494
"AdminSearchEntitiesGQLInput",
9595
"AdminSearchPermissionsGQLInput",
96-
"AdminSearchRoleAssignmentsGQLInput",
96+
"AdminSearchRoleAssignmentsInput",
9797
"SearchRolesInput",
9898
"AssignRoleInput",
9999
"BulkAssignRoleInput",

src/ai/backend/common/dto/manager/v2/rbac/request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
__all__ = (
2424
"AdminSearchEntitiesGQLInput",
2525
"AdminSearchPermissionsGQLInput",
26-
"AdminSearchRoleAssignmentsGQLInput",
26+
"AdminSearchRoleAssignmentsInput",
2727
"SearchRolesInput",
2828
"AssignRoleInput",
2929
"BulkAssignRoleInput",
@@ -303,7 +303,7 @@ class SearchRolesInput(BaseRequestModel):
303303
offset: int | None = None
304304

305305

306-
class AdminSearchRoleAssignmentsGQLInput(BaseRequestModel):
306+
class AdminSearchRoleAssignmentsInput(BaseRequestModel):
307307
"""GQL pagination search input for role assignments."""
308308

309309
filter: RoleAssignmentFilter | None = None

src/ai/backend/manager/api/adapters/rbac.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from uuid import UUID
1111

1212
from ai.backend.common.api_handlers import SENTINEL
13+
from ai.backend.common.contexts.user import current_user
1314
from ai.backend.common.data.filter_specs import StringMatchSpec
1415
from ai.backend.common.data.permission.types import OperationType as InternalOperationType
1516
from ai.backend.common.data.permission.types import RBACElementType
@@ -51,7 +52,7 @@
5152
from ai.backend.common.dto.manager.v2.rbac.request import (
5253
AdminSearchEntitiesGQLInput,
5354
AdminSearchPermissionsGQLInput,
54-
AdminSearchRoleAssignmentsGQLInput,
55+
AdminSearchRoleAssignmentsInput,
5556
SearchRolesInput,
5657
)
5758
from ai.backend.common.dto.manager.v2.rbac.request import (
@@ -111,6 +112,7 @@
111112
from ai.backend.common.dto.manager.v2.rbac.types import (
112113
OrderDirection as OrderDirectionV2,
113114
)
115+
from ai.backend.common.exception import UnreachableError
114116
from ai.backend.manager.actions.action import build_operation_description
115117
from ai.backend.manager.api.adapters.pagination import PaginationSpec
116118
from ai.backend.manager.data.common.types import SearchResult
@@ -680,9 +682,22 @@ async def search_roles_in_scope(
680682
has_previous_page=raw.has_previous_page,
681683
)
682684

683-
async def admin_search_role_assignments_gql(
685+
async def my_search_role_assignments(
684686
self,
685-
input: AdminSearchRoleAssignmentsGQLInput,
687+
input: AdminSearchRoleAssignmentsInput,
688+
) -> SearchResult[RoleAssignmentNode]:
689+
"""Search role assignments for the current authenticated user."""
690+
me = current_user()
691+
if me is None:
692+
raise UnreachableError("User context is not available")
693+
return await self.admin_search_role_assignments(
694+
input,
695+
base_conditions=[AssignedUserConditions.by_user_id(me.user_id)],
696+
)
697+
698+
async def admin_search_role_assignments(
699+
self,
700+
input: AdminSearchRoleAssignmentsInput,
686701
base_conditions: Sequence[QueryCondition] | None = None,
687702
) -> SearchResult[RoleAssignmentNode]:
688703
"""Search role assignments with cursor/offset pagination."""

src/ai/backend/manager/api/gql/rbac/resolver/role.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ai.backend.common.contexts.user import current_user
1111
from ai.backend.common.data.permission.types import RBACElementType
1212
from ai.backend.common.dto.manager.v2.rbac.request import (
13-
AdminSearchRoleAssignmentsGQLInput,
13+
AdminSearchRoleAssignmentsInput,
1414
SearchRolesInput,
1515
)
1616
from ai.backend.manager.api.gql.base import encode_cursor
@@ -125,8 +125,8 @@ async def admin_role_assignments(
125125
offset: int | None = None,
126126
) -> RoleAssignmentConnection:
127127
check_admin_only()
128-
result = await info.context.adapters.rbac.admin_search_role_assignments_gql(
129-
AdminSearchRoleAssignmentsGQLInput(
128+
result = await info.context.adapters.rbac.admin_search_role_assignments(
129+
AdminSearchRoleAssignmentsInput(
130130
filter=filter.to_pydantic() if filter is not None else None,
131131
order=[o.to_pydantic() for o in order_by] if order_by is not None else None,
132132
first=first,
@@ -178,8 +178,8 @@ async def my_roles(
178178

179179
raise InsufficientPrivilege("Authentication required")
180180

181-
result = await info.context.adapters.rbac.admin_search_role_assignments_gql(
182-
AdminSearchRoleAssignmentsGQLInput(
181+
result = await info.context.adapters.rbac.admin_search_role_assignments(
182+
AdminSearchRoleAssignmentsInput(
183183
filter=filter.to_pydantic() if filter is not None else None,
184184
order=[o.to_pydantic() for o in order_by] if order_by is not None else None,
185185
first=first,

src/ai/backend/manager/api/gql/rbac/types/role.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from ai.backend.common.dto.manager.v2.rbac.request import (
1717
AdminSearchEntitiesGQLInput,
1818
AdminSearchPermissionsGQLInput,
19-
AdminSearchRoleAssignmentsGQLInput,
19+
AdminSearchRoleAssignmentsInput,
2020
)
2121
from ai.backend.common.dto.manager.v2.rbac.request import (
2222
AssignRoleInput as AssignRoleInputDTO,
@@ -281,8 +281,8 @@ async def users(
281281
pydantic_filter = combined_filter.to_pydantic() if combined_filter is not None else None
282282
pydantic_order = [o.to_pydantic() for o in order_by] if order_by is not None else None
283283

284-
result = await info.context.adapters.rbac.admin_search_role_assignments_gql(
285-
AdminSearchRoleAssignmentsGQLInput(
284+
result = await info.context.adapters.rbac.admin_search_role_assignments(
285+
AdminSearchRoleAssignmentsInput(
286286
filter=pydantic_filter,
287287
order=pydantic_order,
288288
first=first,

src/ai/backend/manager/api/rest/v2/rbac/handler.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ai.backend.common.dto.manager.v2.rbac.request import (
1212
AdminSearchEntitiesGQLInput,
1313
AdminSearchPermissionsGQLInput,
14-
AdminSearchRoleAssignmentsGQLInput,
14+
AdminSearchRoleAssignmentsInput,
1515
AssignRoleInput,
1616
BulkAssignRoleInput,
1717
BulkRevokeRoleInput,
@@ -191,10 +191,24 @@ async def revoke_role(
191191

192192
async def search_assignments(
193193
self,
194-
body: BodyParam[AdminSearchRoleAssignmentsGQLInput],
194+
body: BodyParam[AdminSearchRoleAssignmentsInput],
195195
) -> APIResponse:
196196
"""Search role assignments with filters, orders, and pagination."""
197-
result = await self._adapter.admin_search_role_assignments_gql(body.parsed)
197+
result = await self._adapter.admin_search_role_assignments(body.parsed)
198+
payload = AdminSearchRoleAssignmentsPayload(
199+
items=result.items,
200+
total_count=result.total_count,
201+
has_next_page=result.has_next_page,
202+
has_previous_page=result.has_previous_page,
203+
)
204+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=payload)
205+
206+
async def my_search_assignments(
207+
self,
208+
body: BodyParam[AdminSearchRoleAssignmentsInput],
209+
) -> APIResponse:
210+
"""Search role assignments for the current authenticated user."""
211+
result = await self._adapter.my_search_role_assignments(body.parsed)
198212
payload = AdminSearchRoleAssignmentsPayload(
199213
items=result.items,
200214
total_count=result.total_count,

0 commit comments

Comments
 (0)