diff --git a/changes/9724.feature.md b/changes/9724.feature.md new file mode 100644 index 00000000000..9f22fec2ab9 --- /dev/null +++ b/changes/9724.feature.md @@ -0,0 +1 @@ +Add GraphQL types and resolver stub for permission update mutation diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 8f47602107a..b0962cd7c69 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -7507,6 +7507,9 @@ type Mutation """Added in 26.3.0. Create a scoped permission (admin only).""" adminCreatePermission(input: CreatePermissionInput!): Permission! @join__field(graph: STRAWBERRY) + """Added in 26.3.0. Update a scoped permission (admin only).""" + adminUpdatePermission(input: UpdatePermissionInput!): Permission! @join__field(graph: STRAWBERRY) + """Added in 26.3.0. Delete a scoped permission (admin only).""" adminDeletePermission(input: DeletePermissionInput!): DeletePermissionPayload! @join__field(graph: STRAWBERRY) @@ -11984,6 +11987,17 @@ type UpdateObjectStoragePayload objectStorage: ObjectStorage! } +"""Added in 26.3.0. Input for updating a scoped permission""" +input UpdatePermissionInput + @join__type(graph: STRAWBERRY) +{ + id: UUID! + scopeType: RBACElementType = null + scopeId: String = null + entityType: RBACElementType = null + operation: OperationType = null +} + """Added in 25.14.0""" input UpdateReservoirRegistryInput @join__type(graph: STRAWBERRY) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 28adb46d89c..9f920722c36 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -3940,6 +3940,9 @@ type Mutation { """Added in 26.3.0. Create a scoped permission (admin only).""" adminCreatePermission(input: CreatePermissionInput!): Permission! + """Added in 26.3.0. Update a scoped permission (admin only).""" + adminUpdatePermission(input: UpdatePermissionInput!): Permission! + """Added in 26.3.0. Delete a scoped permission (admin only).""" adminDeletePermission(input: DeletePermissionInput!): DeletePermissionPayload! @@ -7048,6 +7051,15 @@ type UpdateObjectStoragePayload { objectStorage: ObjectStorage! } +"""Added in 26.3.0. Input for updating a scoped permission""" +input UpdatePermissionInput { + id: UUID! + scopeType: RBACElementType = null + scopeId: String = null + entityType: RBACElementType = null + operation: OperationType = null +} + """Added in 25.14.0""" input UpdateReservoirRegistryInput { id: 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..17ce7ec251b 100644 --- a/src/ai/backend/manager/api/gql/rbac/__init__.py +++ b/src/ai/backend/manager/api/gql/rbac/__init__.py @@ -13,6 +13,7 @@ admin_role, admin_role_assignments, admin_roles, + admin_update_permission, admin_update_role, my_roles, rbac_scope_entity_combinations, @@ -42,6 +43,7 @@ RoleSourceGQL, RoleStatusGQL, ScopeEntityCombinationGQL, + UpdatePermissionInput, UpdateRoleInput, ) @@ -70,6 +72,7 @@ "CreateRoleInput", "UpdateRoleInput", "CreatePermissionInput", + "UpdatePermissionInput", "AssignRoleInput", "RevokeRoleInput", # Connections @@ -91,6 +94,7 @@ "admin_delete_role", "admin_purge_role", "admin_create_permission", + "admin_update_permission", "admin_delete_permission", "admin_assign_role", "admin_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..b3a57cbb034 100644 --- a/src/ai/backend/manager/api/gql/rbac/resolver/__init__.py +++ b/src/ai/backend/manager/api/gql/rbac/resolver/__init__.py @@ -5,6 +5,7 @@ admin_create_permission, admin_delete_permission, admin_permissions, + admin_update_permission, rbac_scope_entity_combinations, ) from .role import ( @@ -28,6 +29,7 @@ "admin_entities", # Permission mutations "admin_create_permission", + "admin_update_permission", "admin_delete_permission", # Role queries "admin_role", diff --git a/src/ai/backend/manager/api/gql/rbac/resolver/permission.py b/src/ai/backend/manager/api/gql/rbac/resolver/permission.py index b83097d4202..458894a03e5 100644 --- a/src/ai/backend/manager/api/gql/rbac/resolver/permission.py +++ b/src/ai/backend/manager/api/gql/rbac/resolver/permission.py @@ -19,6 +19,7 @@ PermissionOrderBy, RBACElementTypeGQL, ScopeEntityCombinationGQL, + UpdatePermissionInput, ) from ai.backend.manager.api.gql.types import StrawberryGQLContext from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow @@ -27,6 +28,9 @@ CreatePermissionAction, DeletePermissionAction, ) +from ai.backend.manager.services.permission_contoller.actions.update_permission import ( + UpdatePermissionAction, +) # ==================== Query Resolvers ==================== @@ -90,6 +94,19 @@ async def admin_create_permission( return PermissionGQL.from_dataclass(action_result.data) +@strawberry.mutation(description="Added in 26.3.0. Update a scoped permission (admin only).") # type: ignore[misc] +async def admin_update_permission( + info: Info[StrawberryGQLContext], + input: UpdatePermissionInput, +) -> PermissionGQL: + action_result = ( + await info.context.processors.permission_controller.update_permission.wait_for_complete( + UpdatePermissionAction(updater=input.to_updater()) + ) + ) + return PermissionGQL.from_dataclass(action_result.data) + + @strawberry.mutation(description="Added in 26.3.0. Delete a scoped permission (admin only).") # type: ignore[misc] async def admin_delete_permission( info: Info[StrawberryGQLContext], 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..5a18805d767 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/__init__.py +++ b/src/ai/backend/manager/api/gql/rbac/types/__init__.py @@ -22,6 +22,7 @@ PermissionOrderField, RBACElementTypeGQL, ScopeEntityCombinationGQL, + UpdatePermissionInput, ) from .role import ( AssignRoleInput, @@ -79,6 +80,7 @@ "EntityOrderBy", # Inputs "CreatePermissionInput", + "UpdatePermissionInput", "DeletePermissionInput", "CreateRoleInput", "UpdateRoleInput", diff --git a/src/ai/backend/manager/api/gql/rbac/types/permission.py b/src/ai/backend/manager/api/gql/rbac/types/permission.py index f72f0563617..b052f3f1c1c 100644 --- a/src/ai/backend/manager/api/gql/rbac/types/permission.py +++ b/src/ai/backend/manager/api/gql/rbac/types/permission.py @@ -23,11 +23,14 @@ from ai.backend.manager.models.rbac_models.permission.permission import PermissionRow from ai.backend.manager.repositories.base import QueryCondition, QueryOrder from ai.backend.manager.repositories.base.creator import Creator +from ai.backend.manager.repositories.base.updater import Updater from ai.backend.manager.repositories.permission_controller.creators import PermissionCreatorSpec from ai.backend.manager.repositories.permission_controller.options import ( ScopedPermissionConditions, ScopedPermissionOrders, ) +from ai.backend.manager.repositories.permission_controller.updaters import PermissionUpdaterSpec +from ai.backend.manager.types import OptionalState if TYPE_CHECKING: from ai.backend.manager.api.gql.rbac.types.role import RoleGQL @@ -333,6 +336,40 @@ def to_creator(self) -> Creator[PermissionRow]: ) +@strawberry.input(description="Added in 26.3.0. Input for updating a scoped permission") +class UpdatePermissionInput: + id: uuid.UUID + scope_type: RBACElementTypeGQL | None = None + scope_id: str | None = None + entity_type: RBACElementTypeGQL | None = None + operation: OperationTypeGQL | None = None + + def to_updater(self) -> Updater[PermissionRow]: + spec = PermissionUpdaterSpec( + scope_type=( + OptionalState.update(self.scope_type.to_element().to_scope_type()) + if self.scope_type is not None + else OptionalState.nop() + ), + scope_id=( + OptionalState.update(self.scope_id) + if self.scope_id is not None + else OptionalState.nop() + ), + entity_type=( + OptionalState.update(self.entity_type.to_element().to_entity_type()) + if self.entity_type is not None + else OptionalState.nop() + ), + operation=( + OptionalState.update(self.operation.to_internal()) + if self.operation is not None + else OptionalState.nop() + ), + ) + return Updater(spec=spec, pk_value=self.id) + + @strawberry.input(description="Added in 26.3.0. Input for deleting a scoped permission") class DeletePermissionInput: id: uuid.UUID diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 70852ca2dc6..187d3743f6e 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -179,6 +179,7 @@ admin_role, admin_role_assignments, admin_roles, + admin_update_permission, admin_update_role, my_roles, rbac_scope_entity_combinations, @@ -479,6 +480,7 @@ class Mutation: admin_delete_role = admin_delete_role admin_purge_role = admin_purge_role admin_create_permission = admin_create_permission + admin_update_permission = admin_update_permission admin_delete_permission = admin_delete_permission admin_assign_role = admin_assign_role admin_revoke_role = admin_revoke_role diff --git a/tests/unit/manager/api/gql/rbac/test_permission_mutations.py b/tests/unit/manager/api/gql/rbac/test_permission_mutations.py new file mode 100644 index 00000000000..03dfd4b5ada --- /dev/null +++ b/tests/unit/manager/api/gql/rbac/test_permission_mutations.py @@ -0,0 +1,222 @@ +"""Tests for permission mutation GraphQL resolvers.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai.backend.common.data.permission.types import ( + EntityType, + OperationType, + ScopeType, +) +from ai.backend.manager.api.gql.rbac.resolver import permission as permission_resolver +from ai.backend.manager.api.gql.rbac.types import PermissionGQL, UpdatePermissionInput +from ai.backend.manager.api.gql.rbac.types.permission import ( + OperationTypeGQL, + RBACElementTypeGQL, +) +from ai.backend.manager.data.permission.permission import PermissionData +from ai.backend.manager.errors.common import ObjectNotFound +from ai.backend.manager.services.permission_contoller.actions.update_permission import ( + UpdatePermissionAction, + UpdatePermissionActionResult, +) + + +def _make_permission_data( + *, + permission_id: uuid.UUID | None = None, + role_id: uuid.UUID | None = None, + scope_type: ScopeType = ScopeType.DOMAIN, + scope_id: str = "default", + entity_type: EntityType = EntityType.VFOLDER, + operation: OperationType = OperationType.READ, +) -> PermissionData: + return PermissionData( + id=permission_id or uuid.uuid4(), + role_id=role_id or uuid.uuid4(), + scope_type=scope_type, + scope_id=scope_id, + entity_type=entity_type, + operation=operation, + ) + + +def _create_mock_context(update_permission_processor: AsyncMock) -> MagicMock: + context = MagicMock() + context.processors = MagicMock() + context.processors.permission_controller = MagicMock() + context.processors.permission_controller.update_permission = update_permission_processor + return context + + +def _create_mock_info(context: MagicMock) -> MagicMock: + info = MagicMock() + info.context = context + return info + + +class TestAdminUpdatePermission: + @pytest.fixture + def mock_processor(self) -> AsyncMock: + processor = AsyncMock() + processor.wait_for_complete = AsyncMock() + return processor + + async def test_calls_processor_with_correct_action( + self, + mock_processor: AsyncMock, + ) -> None: + permission_id = uuid.uuid4() + perm_data = _make_permission_data( + permission_id=permission_id, + operation=OperationType.UPDATE, + ) + mock_processor.wait_for_complete.return_value = UpdatePermissionActionResult( + data=perm_data, + ) + context = _create_mock_context(mock_processor) + info = _create_mock_info(context) + + input_data = UpdatePermissionInput( + id=permission_id, + operation=OperationTypeGQL.UPDATE, + ) + + resolver_fn = permission_resolver.admin_update_permission.base_resolver + result = await resolver_fn(info=info, input=input_data) + + mock_processor.wait_for_complete.assert_called_once() + call_args = mock_processor.wait_for_complete.call_args + action = call_args[0][0] + assert isinstance(action, UpdatePermissionAction) + assert action.updater.pk_value == permission_id + + assert isinstance(result, PermissionGQL) + + async def test_partial_update_only_operation( + self, + mock_processor: AsyncMock, + ) -> None: + permission_id = uuid.uuid4() + perm_data = _make_permission_data( + permission_id=permission_id, + operation=OperationType.UPDATE, + ) + mock_processor.wait_for_complete.return_value = UpdatePermissionActionResult( + data=perm_data, + ) + context = _create_mock_context(mock_processor) + info = _create_mock_info(context) + + input_data = UpdatePermissionInput( + id=permission_id, + operation=OperationTypeGQL.UPDATE, + ) + updater = input_data.to_updater() + values = updater.spec.build_values() + + assert "operation" in values + assert values["operation"] == OperationType.UPDATE + assert "scope_type" not in values + assert "scope_id" not in values + assert "entity_type" not in values + + resolver_fn = permission_resolver.admin_update_permission.base_resolver + result = await resolver_fn(info=info, input=input_data) + assert isinstance(result, PermissionGQL) + + async def test_full_update_all_fields( + self, + mock_processor: AsyncMock, + ) -> None: + permission_id = uuid.uuid4() + perm_data = _make_permission_data( + permission_id=permission_id, + scope_type=ScopeType.PROJECT, + scope_id="project-1", + entity_type=EntityType.SESSION, + operation=OperationType.CREATE, + ) + mock_processor.wait_for_complete.return_value = UpdatePermissionActionResult( + data=perm_data, + ) + context = _create_mock_context(mock_processor) + info = _create_mock_info(context) + + input_data = UpdatePermissionInput( + id=permission_id, + scope_type=RBACElementTypeGQL.PROJECT, + scope_id="project-1", + entity_type=RBACElementTypeGQL.SESSION, + operation=OperationTypeGQL.CREATE, + ) + updater = input_data.to_updater() + values = updater.spec.build_values() + + assert values["scope_type"] == ScopeType.PROJECT + assert values["scope_id"] == "project-1" + assert values["entity_type"] == EntityType.SESSION + assert values["operation"] == OperationType.CREATE + + resolver_fn = permission_resolver.admin_update_permission.base_resolver + result = await resolver_fn(info=info, input=input_data) + assert isinstance(result, PermissionGQL) + + async def test_propagates_object_not_found( + self, + mock_processor: AsyncMock, + ) -> None: + permission_id = uuid.uuid4() + mock_processor.wait_for_complete.side_effect = ObjectNotFound( + f"Permission with ID {permission_id} does not exist." + ) + context = _create_mock_context(mock_processor) + info = _create_mock_info(context) + + input_data = UpdatePermissionInput( + id=permission_id, + operation=OperationTypeGQL.READ, + ) + + resolver_fn = permission_resolver.admin_update_permission.base_resolver + with pytest.raises(ObjectNotFound): + await resolver_fn(info=info, input=input_data) + + async def test_returns_correct_gql_fields( + self, + mock_processor: AsyncMock, + ) -> None: + permission_id = uuid.uuid4() + role_id = uuid.uuid4() + perm_data = _make_permission_data( + permission_id=permission_id, + role_id=role_id, + scope_type=ScopeType.DOMAIN, + scope_id="default", + entity_type=EntityType.VFOLDER, + operation=OperationType.READ, + ) + mock_processor.wait_for_complete.return_value = UpdatePermissionActionResult( + data=perm_data, + ) + context = _create_mock_context(mock_processor) + info = _create_mock_info(context) + + input_data = UpdatePermissionInput( + id=permission_id, + operation=OperationTypeGQL.READ, + ) + + resolver_fn = permission_resolver.admin_update_permission.base_resolver + result = await resolver_fn(info=info, input=input_data) + + assert isinstance(result, PermissionGQL) + assert result.role_id == role_id + assert result.scope_type == RBACElementTypeGQL.DOMAIN + assert result.scope_id == "default" + assert result.entity_type == RBACElementTypeGQL.VFOLDER + assert result.operation == OperationTypeGQL.READ