diff --git a/changes/9725.feature.md b/changes/9725.feature.md new file mode 100644 index 00000000000..70fb692e652 --- /dev/null +++ b/changes/9725.feature.md @@ -0,0 +1 @@ +Add GraphQL types and resolver stubs for bulk role assignment and revocation diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 8f47602107a..cf05954813b 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -1416,6 +1416,38 @@ input BlueGreenConfigInput promoteDelaySeconds: Int! = 0 } +""" +Added in 26.3.0. Error information for a failed user in bulk role assignment. +""" +type BulkAssignRoleError + @join__type(graph: STRAWBERRY) +{ + """UUID of the user that failed.""" + userId: UUID! + + """Error message describing the failure.""" + message: String! +} + +"""Added in 26.3.0. Input for bulk assigning a role to multiple users""" +input BulkAssignRoleInput + @join__type(graph: STRAWBERRY) +{ + roleId: UUID! + userIds: [UUID!]! +} + +"""Added in 26.3.0. Payload for bulk role assignment mutation.""" +type BulkAssignRolePayload + @join__type(graph: STRAWBERRY) +{ + """List of successfully created role assignments.""" + assigned: [RoleAssignment!]! + + """List of errors for users that failed to be assigned.""" + failed: [BulkAssignRoleError!]! +} + """Added in 26.2.0. Payload for bulk user creation mutation.""" type BulkCreateUsersV2Payload @join__type(graph: STRAWBERRY) @@ -1502,6 +1534,38 @@ type BulkPurgeUserV2Error message: String! } +""" +Added in 26.3.0. Error information for a failed user in bulk role revocation. +""" +type BulkRevokeRoleError + @join__type(graph: STRAWBERRY) +{ + """UUID of the user that failed.""" + userId: UUID! + + """Error message describing the failure.""" + message: String! +} + +"""Added in 26.3.0. Input for bulk revoking a role from multiple users""" +input BulkRevokeRoleInput + @join__type(graph: STRAWBERRY) +{ + roleId: UUID! + userIds: [UUID!]! +} + +"""Added in 26.3.0. Payload for bulk role revocation mutation.""" +type BulkRevokeRolePayload + @join__type(graph: STRAWBERRY) +{ + """List of successfully revoked role assignments.""" + revoked: [RoleAssignment!]! + + """List of errors for users that failed to be revoked.""" + failed: [BulkRevokeRoleError!]! +} + """Added in 26.3.0. Payload for bulk user update mutation.""" type BulkUpdateUsersV2Payload @join__type(graph: STRAWBERRY) @@ -7515,6 +7579,12 @@ type Mutation """Added in 26.3.0. Revoke a role from a user (admin only).""" adminRevokeRole(input: RevokeRoleInput!): RoleAssignment! @join__field(graph: STRAWBERRY) + + """Added in 26.3.0. Bulk assign a role to multiple users (admin only).""" + adminBulkAssignRole(input: BulkAssignRoleInput!): BulkAssignRolePayload! @join__field(graph: STRAWBERRY) + + """Added in 26.3.0. Bulk revoke a role from multiple users (admin only).""" + adminBulkRevokeRole(input: BulkRevokeRoleInput!): BulkRevokeRolePayload! @join__field(graph: STRAWBERRY) } """Added in 24.12.0.""" diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 28adb46d89c..001d409980f 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -903,6 +903,32 @@ input BlueGreenConfigInput { promoteDelaySeconds: Int! = 0 } +""" +Added in 26.3.0. Error information for a failed user in bulk role assignment. +""" +type BulkAssignRoleError { + """UUID of the user that failed.""" + userId: UUID! + + """Error message describing the failure.""" + message: String! +} + +"""Added in 26.3.0. Input for bulk assigning a role to multiple users""" +input BulkAssignRoleInput { + roleId: UUID! + userIds: [UUID!]! +} + +"""Added in 26.3.0. Payload for bulk role assignment mutation.""" +type BulkAssignRolePayload { + """List of successfully created role assignments.""" + assigned: [RoleAssignment!]! + + """List of errors for users that failed to be assigned.""" + failed: [BulkAssignRoleError!]! +} + """Added in 26.2.0. Error information for a failed user in bulk creation.""" type BulkCreateUserV2Error { """Original position in the input list.""" @@ -975,6 +1001,32 @@ type BulkPurgeUsersV2Payload { failed: [BulkPurgeUserV2Error!]! } +""" +Added in 26.3.0. Error information for a failed user in bulk role revocation. +""" +type BulkRevokeRoleError { + """UUID of the user that failed.""" + userId: UUID! + + """Error message describing the failure.""" + message: String! +} + +"""Added in 26.3.0. Input for bulk revoking a role from multiple users""" +input BulkRevokeRoleInput { + roleId: UUID! + userIds: [UUID!]! +} + +"""Added in 26.3.0. Payload for bulk role revocation mutation.""" +type BulkRevokeRolePayload { + """List of successfully revoked role assignments.""" + revoked: [RoleAssignment!]! + + """List of errors for users that failed to be revoked.""" + failed: [BulkRevokeRoleError!]! +} + """Added in 26.3.0. Error information for a failed user in bulk update.""" type BulkUpdateUserV2Error { """UUID of the user that failed to update.""" @@ -3948,6 +4000,12 @@ type Mutation { """Added in 26.3.0. Revoke a role from a user (admin only).""" adminRevokeRole(input: RevokeRoleInput!): RoleAssignment! + + """Added in 26.3.0. Bulk assign a role to multiple users (admin only).""" + adminBulkAssignRole(input: BulkAssignRoleInput!): BulkAssignRolePayload! + + """Added in 26.3.0. Bulk revoke a role from multiple users (admin only).""" + adminBulkRevokeRole(input: BulkRevokeRoleInput!): BulkRevokeRolePayload! } """An object with a Globally Unique ID""" diff --git a/src/ai/backend/manager/api/gql/rbac/__init__.py b/src/ai/backend/manager/api/gql/rbac/__init__.py index d3dab20d83c..7165cafe523 100644 --- a/src/ai/backend/manager/api/gql/rbac/__init__.py +++ b/src/ai/backend/manager/api/gql/rbac/__init__.py @@ -2,6 +2,8 @@ from .resolver import ( admin_assign_role, + admin_bulk_assign_role, + admin_bulk_revoke_role, admin_create_permission, admin_create_role, admin_delete_permission, @@ -19,6 +21,10 @@ ) from .types import ( AssignRoleInput, + BulkAssignRoleInput, + BulkAssignRolePayloadGQL, + BulkRevokeRoleInput, + BulkRevokeRolePayloadGQL, CreatePermissionInput, CreateRoleInput, EntityConnection, @@ -72,6 +78,11 @@ "CreatePermissionInput", "AssignRoleInput", "RevokeRoleInput", + "BulkAssignRoleInput", + "BulkRevokeRoleInput", + # Payloads + "BulkAssignRolePayloadGQL", + "BulkRevokeRolePayloadGQL", # Connections "RoleConnection", "PermissionConnection", @@ -94,4 +105,6 @@ "admin_delete_permission", "admin_assign_role", "admin_revoke_role", + "admin_bulk_assign_role", + "admin_bulk_revoke_role", ) diff --git a/src/ai/backend/manager/api/gql/rbac/resolver/__init__.py b/src/ai/backend/manager/api/gql/rbac/resolver/__init__.py index b46f95d0625..bdda67f8c35 100644 --- a/src/ai/backend/manager/api/gql/rbac/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/rbac/resolver/__init__.py @@ -9,6 +9,8 @@ ) from .role import ( admin_assign_role, + admin_bulk_assign_role, + admin_bulk_revoke_role, admin_create_role, admin_delete_role, admin_purge_role, @@ -41,4 +43,6 @@ "admin_purge_role", "admin_assign_role", "admin_revoke_role", + "admin_bulk_assign_role", + "admin_bulk_revoke_role", ] diff --git a/src/ai/backend/manager/api/gql/rbac/resolver/role.py b/src/ai/backend/manager/api/gql/rbac/resolver/role.py index add192b4e4d..60961c5d8c8 100644 --- a/src/ai/backend/manager/api/gql/rbac/resolver/role.py +++ b/src/ai/backend/manager/api/gql/rbac/resolver/role.py @@ -16,6 +16,12 @@ ) from ai.backend.manager.api.gql.rbac.types import ( AssignRoleInput, + BulkAssignRoleErrorGQL, + BulkAssignRoleInput, + BulkAssignRolePayloadGQL, + BulkRevokeRoleErrorGQL, + BulkRevokeRoleInput, + BulkRevokeRolePayloadGQL, CreateRoleInput, DeleteRoleInput, DeleteRolePayload, @@ -41,6 +47,12 @@ from ai.backend.manager.services.permission_contoller.actions.assign_role import ( AssignRoleAction, ) +from ai.backend.manager.services.permission_contoller.actions.bulk_assign_role import ( + BulkAssignRoleAction, +) +from ai.backend.manager.services.permission_contoller.actions.bulk_revoke_role import ( + BulkRevokeRoleAction, +) from ai.backend.manager.services.permission_contoller.actions.create_role import ( CreateRoleAction, ) @@ -239,3 +251,45 @@ async def admin_revoke_role( ) ) return RoleAssignmentGQL.from_revocation_data(action_result.data) + + +@strawberry.mutation( + description="Added in 26.3.0. Bulk assign a role to multiple users (admin only)." +) # type: ignore[misc] +async def admin_bulk_assign_role( + info: Info[StrawberryGQLContext], + input: BulkAssignRoleInput, +) -> BulkAssignRolePayloadGQL: + action_result = ( + await info.context.processors.permission_controller.bulk_assign_role.wait_for_complete( + BulkAssignRoleAction(bulk_creator=input.to_bulk_creator()) + ) + ) + return BulkAssignRolePayloadGQL( + assigned=[RoleAssignmentGQL.from_assignment_data(s) for s in action_result.data.successes], + failed=[ + BulkAssignRoleErrorGQL(user_id=f.user_id, message=f.message) + for f in action_result.data.failures + ], + ) + + +@strawberry.mutation( + description="Added in 26.3.0. Bulk revoke a role from multiple users (admin only)." +) # type: ignore[misc] +async def admin_bulk_revoke_role( + info: Info[StrawberryGQLContext], + input: BulkRevokeRoleInput, +) -> BulkRevokeRolePayloadGQL: + action_result = ( + await info.context.processors.permission_controller.bulk_revoke_role.wait_for_complete( + BulkRevokeRoleAction(input=input.to_input()) + ) + ) + return BulkRevokeRolePayloadGQL( + revoked=[RoleAssignmentGQL.from_revocation_data(s) for s in action_result.data.successes], + failed=[ + BulkRevokeRoleErrorGQL(user_id=f.user_id, message=f.message) + for f in action_result.data.failures + ], + ) diff --git a/src/ai/backend/manager/api/gql/rbac/types/__init__.py b/src/ai/backend/manager/api/gql/rbac/types/__init__.py index 46845819de8..41fb38077c8 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/__init__.py +++ b/src/ai/backend/manager/api/gql/rbac/types/__init__.py @@ -25,6 +25,12 @@ ) from .role import ( AssignRoleInput, + BulkAssignRoleErrorGQL, + BulkAssignRoleInput, + BulkAssignRolePayloadGQL, + BulkRevokeRoleErrorGQL, + BulkRevokeRoleInput, + BulkRevokeRolePayloadGQL, CreateRoleInput, DeleteRoleInput, DeleteRolePayload, @@ -86,10 +92,16 @@ "PurgeRoleInput", "AssignRoleInput", "RevokeRoleInput", + "BulkAssignRoleInput", + "BulkRevokeRoleInput", # Payloads "DeletePermissionPayload", "DeleteRolePayload", "PurgeRolePayload", + "BulkAssignRoleErrorGQL", + "BulkAssignRolePayloadGQL", + "BulkRevokeRoleErrorGQL", + "BulkRevokeRolePayloadGQL", # Connections "PermissionConnection", "PermissionEdge", diff --git a/src/ai/backend/manager/api/gql/rbac/types/role.py b/src/ai/backend/manager/api/gql/rbac/types/role.py index 86186ed0155..41e64097a2b 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/role.py +++ b/src/ai/backend/manager/api/gql/rbac/types/role.py @@ -20,6 +20,7 @@ from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy, StrawberryGQLContext from ai.backend.manager.data.permission.role import ( AssignedUserData, + BulkUserRoleRevocationInput, RoleData, RoleDetailData, UserRoleAssignmentData, @@ -28,10 +29,14 @@ UserRoleRevocationInput, ) from ai.backend.manager.models.rbac_models.role import RoleRow +from ai.backend.manager.models.rbac_models.user_role import UserRoleRow from ai.backend.manager.repositories.base import QueryCondition, QueryOrder -from ai.backend.manager.repositories.base.creator import Creator +from ai.backend.manager.repositories.base.creator import BulkCreator, Creator from ai.backend.manager.repositories.base.updater import Updater -from ai.backend.manager.repositories.permission_controller.creators import RoleCreatorSpec +from ai.backend.manager.repositories.permission_controller.creators import ( + RoleCreatorSpec, + UserRoleCreatorSpec, +) from ai.backend.manager.repositories.permission_controller.options import ( AssignedUserConditions, AssignedUserOrders, @@ -400,6 +405,28 @@ def to_input(self) -> UserRoleRevocationInput: ) +@strawberry.input(description="Added in 26.3.0. Input for bulk assigning a role to multiple users") +class BulkAssignRoleInput: + role_id: uuid.UUID + user_ids: list[uuid.UUID] + + def to_bulk_creator(self) -> BulkCreator[UserRoleRow]: + specs = [UserRoleCreatorSpec(user_id=uid, role_id=self.role_id) for uid in self.user_ids] + return BulkCreator(specs=specs) + + +@strawberry.input(description="Added in 26.3.0. Input for bulk revoking a role from multiple users") +class BulkRevokeRoleInput: + role_id: uuid.UUID + user_ids: list[uuid.UUID] + + def to_input(self) -> BulkUserRoleRevocationInput: + return BulkUserRoleRevocationInput( + role_id=self.role_id, + user_ids=self.user_ids, + ) + + @strawberry.input(description="Added in 26.3.0. Input for soft-deleting a role") class DeleteRoleInput: id: uuid.UUID @@ -423,6 +450,50 @@ class PurgeRolePayload: id: ID +@strawberry.type( + name="BulkAssignRoleError", + description="Added in 26.3.0. Error information for a failed user in bulk role assignment.", +) +class BulkAssignRoleErrorGQL: + user_id: uuid.UUID = strawberry.field(description="UUID of the user that failed.") + message: str = strawberry.field(description="Error message describing the failure.") + + +@strawberry.type( + name="BulkAssignRolePayload", + description="Added in 26.3.0. Payload for bulk role assignment mutation.", +) +class BulkAssignRolePayloadGQL: + assigned: list[RoleAssignmentGQL] = strawberry.field( + description="List of successfully created role assignments." + ) + failed: list[BulkAssignRoleErrorGQL] = strawberry.field( + description="List of errors for users that failed to be assigned." + ) + + +@strawberry.type( + name="BulkRevokeRoleError", + description="Added in 26.3.0. Error information for a failed user in bulk role revocation.", +) +class BulkRevokeRoleErrorGQL: + user_id: uuid.UUID = strawberry.field(description="UUID of the user that failed.") + message: str = strawberry.field(description="Error message describing the failure.") + + +@strawberry.type( + name="BulkRevokeRolePayload", + description="Added in 26.3.0. Payload for bulk role revocation mutation.", +) +class BulkRevokeRolePayloadGQL: + revoked: list[RoleAssignmentGQL] = strawberry.field( + description="List of successfully revoked role assignments." + ) + failed: list[BulkRevokeRoleErrorGQL] = strawberry.field( + description="List of errors for users that failed to be revoked." + ) + + # ==================== Connection Types ==================== diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 70852ca2dc6..0f4d9553b73 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -168,6 +168,8 @@ ) from .rbac import ( admin_assign_role, + admin_bulk_assign_role, + admin_bulk_revoke_role, admin_create_permission, admin_create_role, admin_delete_permission, @@ -482,6 +484,8 @@ class Mutation: admin_delete_permission = admin_delete_permission admin_assign_role = admin_assign_role admin_revoke_role = admin_revoke_role + admin_bulk_assign_role = admin_bulk_assign_role + admin_bulk_revoke_role = admin_bulk_revoke_role @strawberry.type diff --git a/tests/unit/manager/api/gql/rbac/test_bulk_role_resolvers.py b/tests/unit/manager/api/gql/rbac/test_bulk_role_resolvers.py new file mode 100644 index 00000000000..f69621fd5fe --- /dev/null +++ b/tests/unit/manager/api/gql/rbac/test_bulk_role_resolvers.py @@ -0,0 +1,178 @@ +"""Tests for adminBulkAssignRole and adminBulkRevokeRole GraphQL resolvers.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai.backend.manager.api.gql.rbac.resolver import role as role_resolver +from ai.backend.manager.api.gql.rbac.types import ( + BulkAssignRoleInput, + BulkAssignRolePayloadGQL, + BulkRevokeRoleInput, + BulkRevokeRolePayloadGQL, +) +from ai.backend.manager.data.permission.role import ( + BulkRoleAssignmentFailure, + BulkRoleAssignmentResultData, + BulkRoleRevocationFailure, + BulkRoleRevocationResultData, + UserRoleAssignmentData, + UserRoleRevocationData, +) +from ai.backend.manager.services.permission_contoller.actions.bulk_assign_role import ( + BulkAssignRoleActionResult, +) +from ai.backend.manager.services.permission_contoller.actions.bulk_revoke_role import ( + BulkRevokeRoleActionResult, +) + + +class TestAdminBulkAssignRole: + @pytest.fixture + def mock_processor(self) -> AsyncMock: + processor = AsyncMock() + processor.wait_for_complete = AsyncMock() + return processor + + @pytest.fixture + def mock_info(self, mock_processor: AsyncMock) -> MagicMock: + info = MagicMock() + info.context.processors.permission_controller.bulk_assign_role = mock_processor + return info + + async def test_returns_payload_with_assigned_and_failed( + self, + mock_info: MagicMock, + mock_processor: AsyncMock, + ) -> None: + role_id = uuid.uuid4() + user_id_success = uuid.uuid4() + user_id_fail = uuid.uuid4() + + mock_processor.wait_for_complete.return_value = BulkAssignRoleActionResult( + data=BulkRoleAssignmentResultData( + successes=[ + UserRoleAssignmentData( + id=uuid.uuid4(), + user_id=user_id_success, + role_id=role_id, + granted_by=None, + ) + ], + failures=[ + BulkRoleAssignmentFailure(user_id=user_id_fail, message="Role already assigned") + ], + ) + ) + + input_data = BulkAssignRoleInput(role_id=role_id, user_ids=[user_id_success, user_id_fail]) + + resolver_fn = role_resolver.admin_bulk_assign_role.base_resolver + result = await resolver_fn(mock_info, input_data) + + assert isinstance(result, BulkAssignRolePayloadGQL) + assert len(result.assigned) == 1 + assert result.assigned[0].user_id == user_id_success + assert len(result.failed) == 1 + assert result.failed[0].user_id == user_id_fail + assert result.failed[0].message == "Role already assigned" + + async def test_constructs_action_from_input( + self, + mock_info: MagicMock, + mock_processor: AsyncMock, + ) -> None: + role_id = uuid.uuid4() + user_ids = [uuid.uuid4(), uuid.uuid4()] + + mock_processor.wait_for_complete.return_value = BulkAssignRoleActionResult( + data=BulkRoleAssignmentResultData(successes=[], failures=[]) + ) + + input_data = BulkAssignRoleInput(role_id=role_id, user_ids=user_ids) + + resolver_fn = role_resolver.admin_bulk_assign_role.base_resolver + await resolver_fn(mock_info, input_data) + + mock_processor.wait_for_complete.assert_called_once() + action = mock_processor.wait_for_complete.call_args[0][0] + specs = action.bulk_creator.specs + assert len(specs) == len(user_ids) + for spec, uid in zip(specs, user_ids, strict=True): + assert spec.user_id == uid + assert spec.role_id == role_id + + +class TestAdminBulkRevokeRole: + @pytest.fixture + def mock_processor(self) -> AsyncMock: + processor = AsyncMock() + processor.wait_for_complete = AsyncMock() + return processor + + @pytest.fixture + def mock_info(self, mock_processor: AsyncMock) -> MagicMock: + info = MagicMock() + info.context.processors.permission_controller.bulk_revoke_role = mock_processor + return info + + async def test_returns_payload_with_revoked_and_failed( + self, + mock_info: MagicMock, + mock_processor: AsyncMock, + ) -> None: + role_id = uuid.uuid4() + user_id_success = uuid.uuid4() + user_id_fail = uuid.uuid4() + + mock_processor.wait_for_complete.return_value = BulkRevokeRoleActionResult( + data=BulkRoleRevocationResultData( + successes=[ + UserRoleRevocationData( + user_role_id=uuid.uuid4(), + user_id=user_id_success, + role_id=role_id, + ) + ], + failures=[ + BulkRoleRevocationFailure(user_id=user_id_fail, message="Role not assigned") + ], + ) + ) + + input_data = BulkRevokeRoleInput(role_id=role_id, user_ids=[user_id_success, user_id_fail]) + + resolver_fn = role_resolver.admin_bulk_revoke_role.base_resolver + result = await resolver_fn(mock_info, input_data) + + assert isinstance(result, BulkRevokeRolePayloadGQL) + assert len(result.revoked) == 1 + assert result.revoked[0].user_id == user_id_success + assert len(result.failed) == 1 + assert result.failed[0].user_id == user_id_fail + assert result.failed[0].message == "Role not assigned" + + async def test_constructs_action_from_input( + self, + mock_info: MagicMock, + mock_processor: AsyncMock, + ) -> None: + role_id = uuid.uuid4() + user_ids = [uuid.uuid4(), uuid.uuid4()] + + mock_processor.wait_for_complete.return_value = BulkRevokeRoleActionResult( + data=BulkRoleRevocationResultData(successes=[], failures=[]) + ) + + input_data = BulkRevokeRoleInput(role_id=role_id, user_ids=user_ids) + + resolver_fn = role_resolver.admin_bulk_revoke_role.base_resolver + await resolver_fn(mock_info, input_data) + + mock_processor.wait_for_complete.assert_called_once() + action = mock_processor.wait_for_complete.call_args[0][0] + assert action.input.role_id == role_id + assert action.input.user_ids == user_ids