diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py index 448532fda4ca..161834769b90 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py @@ -1,4 +1,5 @@ import logging +from typing import Literal from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE from models_library.api_schemas_webserver.functions import ( @@ -18,6 +19,7 @@ ) from models_library.functions import ( FunctionClass, + FunctionGroupAccessRights, FunctionJobStatus, FunctionOutputs, FunctionUserAccessRights, @@ -418,6 +420,7 @@ async def update_function_job_status( product_name: ProductName, function_job_id: FunctionJobID, job_status: FunctionJobStatus, + check_write_permissions: bool = True, ) -> FunctionJobStatus: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, @@ -426,6 +429,7 @@ async def update_function_job_status( job_status=job_status, user_id=user_id, product_name=product_name, + check_write_permissions=check_write_permissions, ) return TypeAdapter(FunctionJobStatus).validate_python(result) @@ -438,6 +442,7 @@ async def update_function_job_outputs( product_name: ProductName, function_job_id: FunctionJobID, outputs: FunctionOutputs, + check_write_permissions: bool = True, ) -> FunctionOutputs: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, @@ -446,6 +451,7 @@ async def update_function_job_outputs( outputs=outputs, user_id=user_id, product_name=product_name, + check_write_permissions=check_write_permissions, ) return TypeAdapter(FunctionOutputs).validate_python(result) @@ -578,3 +584,37 @@ async def get_functions_user_api_access_rights( product_name=product_name, ) return TypeAdapter(FunctionUserApiAccessRights).validate_python(result) + + +@log_decorator(_logger, level=logging.DEBUG) +async def set_group_permissions( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: UserID, + product_name: ProductName, + object_type: Literal["function", "function_job", "function_job_collection"], + object_ids: list[FunctionID | FunctionJobID | FunctionJobCollectionID], + permission_group_id: int, + read: bool | None = None, + write: bool | None = None, + execute: bool | None = None, +) -> list[ + tuple[ + FunctionID | FunctionJobID | FunctionJobCollectionID, FunctionGroupAccessRights + ] +]: + result = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("set_group_permissions"), + user_id=user_id, + product_name=product_name, + object_type=object_type, + object_ids=object_ids, + permission_group_id=permission_group_id, + read=read, + write=write, + execute=execute, + ) + return TypeAdapter( + list[tuple[FunctionID | FunctionJobID, FunctionGroupAccessRights]] + ).validate_python(result) diff --git a/services/api-server/src/simcore_service_api_server/_service_function_jobs.py b/services/api-server/src/simcore_service_api_server/_service_function_jobs.py index e89d910d47a0..815915d753b4 100644 --- a/services/api-server/src/simcore_service_api_server/_service_function_jobs.py +++ b/services/api-server/src/simcore_service_api_server/_service_function_jobs.py @@ -203,6 +203,7 @@ async def inspect_function_job( user_id=self.user_id, product_name=self.product_name, job_status=new_job_status, + check_write_permissions=False, ) async def create_function_job_inputs( # pylint: disable=no-self-use @@ -529,4 +530,5 @@ async def function_job_outputs( user_id=user_id, product_name=product_name, outputs=new_outputs, + check_write_permissions=False, ) diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py index 838406922d8d..405b16bc1149 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py @@ -587,6 +587,7 @@ async def update_function_job_status( user_id: UserID, product_name: ProductName, job_status: FunctionJobStatus, + check_write_permissions: bool = True, ) -> FunctionJobStatus: return await functions_rpc_interface.update_function_job_status( self._client, @@ -594,6 +595,7 @@ async def update_function_job_status( user_id=user_id, product_name=product_name, job_status=job_status, + check_write_permissions=check_write_permissions, ) async def update_function_job_outputs( @@ -603,6 +605,7 @@ async def update_function_job_outputs( user_id: UserID, product_name: ProductName, outputs: FunctionOutputs, + check_write_permissions: bool = True, ) -> FunctionOutputs: return await functions_rpc_interface.update_function_job_outputs( self._client, @@ -610,6 +613,7 @@ async def update_function_job_outputs( user_id=user_id, product_name=product_name, outputs=outputs, + check_write_permissions=check_write_permissions, ) async def find_cached_function_jobs( diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py index bd77c9123e69..3179231c4a50 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py @@ -169,7 +169,7 @@ async def register_function(request: web.Request) -> web.Response: @login_required @permission_required("function.read") @handle_rest_requests_exceptions -async def list_functions(request: web.Request) -> web.Response: +async def list_functions(request: web.Request) -> web.Response: # noqa: C901 query_params: FunctionsListQueryParams = parse_request_query_parameters_as( FunctionsListQueryParams, request ) diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py index 5b2e98f60dc0..07919238214a 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py @@ -1,9 +1,12 @@ +from typing import Literal + from aiohttp import web from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE from models_library.functions import ( Function, FunctionAccessRights, FunctionClass, + FunctionGroupAccessRights, FunctionID, FunctionInputs, FunctionInputSchema, @@ -43,6 +46,7 @@ UnsupportedFunctionClassError, UnsupportedFunctionJobClassError, ) +from models_library.groups import GroupID from models_library.products import ProductName from models_library.rest_ordering import OrderBy from models_library.rest_pagination import PageMetaInfoLimitOffset @@ -495,7 +499,13 @@ async def get_function_job_outputs( ) -@router.expose(reraise_if_error_type=(FunctionJobIDNotFoundError,)) +@router.expose( + reraise_if_error_type=( + FunctionJobIDNotFoundError, + FunctionJobWriteAccessDeniedError, + FunctionJobReadAccessDeniedError, + ) +) async def update_function_job_status( app: web.Application, *, @@ -503,6 +513,7 @@ async def update_function_job_status( product_name: ProductName, function_job_id: FunctionJobID, job_status: FunctionJobStatus, + check_write_permissions: bool = True, ) -> FunctionJobStatus: return await _functions_service.update_function_job_status( app=app, @@ -510,10 +521,17 @@ async def update_function_job_status( product_name=product_name, function_job_id=function_job_id, job_status=job_status, + check_write_permissions=check_write_permissions, ) -@router.expose(reraise_if_error_type=(FunctionJobIDNotFoundError,)) +@router.expose( + reraise_if_error_type=( + FunctionJobIDNotFoundError, + FunctionJobWriteAccessDeniedError, + FunctionJobReadAccessDeniedError, + ) +) async def update_function_job_outputs( app: web.Application, *, @@ -521,6 +539,7 @@ async def update_function_job_outputs( product_name: ProductName, function_job_id: FunctionJobID, outputs: FunctionOutputs, + check_write_permissions: bool = True, ) -> FunctionOutputs: return await _functions_service.update_function_job_outputs( app=app, @@ -528,6 +547,7 @@ async def update_function_job_outputs( product_name=product_name, function_job_id=function_job_id, outputs=outputs, + check_write_permissions=check_write_permissions, ) @@ -586,3 +606,33 @@ async def get_functions_user_api_access_rights( async def register_rpc_routes_on_startup(app: web.Application): rpc_server = get_rabbitmq_rpc_server(app) await rpc_server.register_router(router, WEBSERVER_RPC_NAMESPACE, app) + + +@router.expose(reraise_if_error_type=()) +async def set_group_permissions( + app: web.Application, + *, + user_id: UserID, + permission_group_id: GroupID, + product_name: ProductName, + object_type: Literal["function", "function_job", "function_job_collection"], + object_ids: list[FunctionID | FunctionJobID | FunctionJobCollectionID], + read: bool | None = None, + write: bool | None = None, + execute: bool | None = None, +) -> list[ + tuple[ + FunctionID | FunctionJobID | FunctionJobCollectionID, FunctionGroupAccessRights + ] +]: + return await _functions_service.set_group_permissions( + app=app, + user_id=user_id, + permission_group_id=permission_group_id, + product_name=product_name, + object_type=object_type, + object_ids=object_ids, + read=read, + write=write, + execute=execute, + ) diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py index 620f73bd3e2d..cf80e7cf7d2a 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py @@ -663,22 +663,10 @@ async def update_function_job_status( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, - product_name: ProductName, function_job_id: FunctionJobID, job_status: FunctionJobStatus, ) -> FunctionJobStatus: async with transaction_context(get_asyncpg_engine(app), connection) as transaction: - await check_user_permissions( - app, - connection=transaction, - user_id=user_id, - product_name=product_name, - object_type="function_job", - object_id=function_job_id, - permissions=["write"], - ) - result = await transaction.execute( function_jobs_table.update() .where(function_jobs_table.c.uuid == function_job_id) @@ -697,22 +685,10 @@ async def update_function_job_outputs( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, - product_name: ProductName, function_job_id: FunctionJobID, outputs: FunctionOutputs, ) -> FunctionOutputs: async with transaction_context(get_asyncpg_engine(app), connection) as transaction: - await check_user_permissions( - app, - connection=transaction, - user_id=user_id, - product_name=product_name, - object_type="function_job", - object_id=function_job_id, - permissions=["write"], - ) - result = await transaction.execute( function_jobs_table.update() .where(function_jobs_table.c.uuid == function_job_id) @@ -1141,11 +1117,15 @@ async def set_group_permissions( permission_group_id: GroupID, product_name: ProductName, object_type: Literal["function", "function_job", "function_job_collection"], - object_ids: list[UUID], + object_ids: list[FunctionID | FunctionJobID | FunctionJobCollectionID], read: bool | None = None, write: bool | None = None, execute: bool | None = None, -) -> list[tuple[UUID, FunctionGroupAccessRights]]: +) -> list[ + tuple[ + FunctionID | FunctionJobID | FunctionJobCollectionID, FunctionGroupAccessRights + ] +]: async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: for object_id in object_ids: await check_user_permissions( @@ -1245,8 +1225,13 @@ async def _internal_get_group_permissions( *, product_name: ProductName, object_type: Literal["function", "function_job", "function_job_collection"], - object_ids: list[UUID], -) -> list[tuple[UUID, list[FunctionGroupAccessRights]]]: + object_ids: list[FunctionID | FunctionJobID | FunctionJobCollectionID], +) -> list[ + tuple[ + FunctionID | FunctionJobID | FunctionJobCollectionID, + list[FunctionGroupAccessRights], + ] +]: access_rights_table = None field_name = None if object_type == "function": diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_service.py b/services/web/server/src/simcore_service_webserver/functions/_functions_service.py index 32920167cfe3..74cbf189484a 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_service.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_service.py @@ -1,3 +1,5 @@ +from typing import Literal + from aiohttp import web from models_library.basic_types import IDStr from models_library.functions import ( @@ -607,6 +609,35 @@ async def remove_function_group_permissions( ) +async def set_group_permissions( + app: web.Application, + *, + user_id: UserID, + permission_group_id: GroupID, + product_name: ProductName, + object_type: Literal["function", "function_job", "function_job_collection"], + object_ids: list[FunctionID | FunctionJobID | FunctionJobCollectionID], + read: bool | None = None, + write: bool | None = None, + execute: bool | None = None, +) -> list[ + tuple[ + FunctionID | FunctionJobID | FunctionJobCollectionID, FunctionGroupAccessRights + ] +]: + return await _functions_repository.set_group_permissions( + app=app, + user_id=user_id, + product_name=product_name, + object_type=object_type, + object_ids=object_ids, + permission_group_id=permission_group_id, + read=read, + write=write, + execute=execute, + ) + + async def get_function_job_status( app: web.Application, *, @@ -644,11 +675,22 @@ async def update_function_job_outputs( product_name: ProductName, function_job_id: FunctionJobID, outputs: FunctionOutputs, + check_write_permissions: bool = True, ) -> FunctionOutputs: - return await _functions_repository.update_function_job_outputs( - app=app, + checked_permissions: list[Literal["read", "write", "execute"]] = ["read"] + if check_write_permissions: + checked_permissions.append("write") + await _functions_repository.check_user_permissions( + app, user_id=user_id, product_name=product_name, + object_type="function_job", + object_id=function_job_id, + permissions=checked_permissions, + ) + + return await _functions_repository.update_function_job_outputs( + app=app, function_job_id=function_job_id, outputs=outputs, ) @@ -661,11 +703,22 @@ async def update_function_job_status( product_name: ProductName, function_job_id: FunctionJobID, job_status: FunctionJobStatus, + check_write_permissions: bool = True, ) -> FunctionJobStatus: - return await _functions_repository.update_function_job_status( - app=app, + checked_permissions: list[Literal["read", "write", "execute"]] = ["read"] + + if check_write_permissions: + checked_permissions.append("write") + await _functions_repository.check_user_permissions( + app, user_id=user_id, product_name=product_name, + object_type="function_job", + object_id=function_job_id, + permissions=checked_permissions, + ) + return await _functions_repository.update_function_job_status( + app=app, function_job_id=function_job_id, job_status=job_status, ) diff --git a/services/web/server/tests/unit/with_dbs/04/functions/conftest.py b/services/web/server/tests/unit/with_dbs/04/functions/conftest.py index efe3aaeaea8b..f507d9bdf5c2 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions/conftest.py @@ -71,7 +71,6 @@ async def rpc_client( @pytest.fixture def mock_function_factory() -> Callable[[FunctionClass], Function]: - def _(function_class: FunctionClass) -> Function: if function_class == FunctionClass.PROJECT: return ProjectFunction( @@ -112,9 +111,8 @@ def _(function_class: FunctionClass) -> Function: solver_key="simcore/services/comp/mysolver", solver_version="1.0.0", ) - raise AssertionError( - f"Please implement the mock for {function_class=} yourself" - ) + msg = f"Please implement the mock for {function_class=} yourself" + raise AssertionError(msg) return _ diff --git a/services/web/server/tests/unit/with_dbs/04/functions/test_function_jobs_controller_rpc.py b/services/web/server/tests/unit/with_dbs/04/functions/test_function_jobs_controller_rpc.py index 09db242f505a..4869b81ba33c 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions/test_function_jobs_controller_rpc.py +++ b/services/web/server/tests/unit/with_dbs/04/functions/test_function_jobs_controller_rpc.py @@ -1,5 +1,6 @@ # pylint: disable=redefined-outer-name # pylint: disable=unused-argument +# pylint: disable=too-many-arguments import datetime from collections.abc import Callable @@ -662,13 +663,26 @@ async def test_incompatible_patch_model_error( "user_role", [UserRole.USER], ) -async def test_update_function_job_status( +@pytest.mark.parametrize( + "access_by_other_user, check_write_permissions, expected_to_raise", + [(False, False, None), (True, True, FunctionJobWriteAccessDeniedError)], +) +@pytest.mark.parametrize( + "status_or_output", + ["status", "output"], +) +async def test_update_function_job_status_output( client: TestClient, rpc_client: RabbitMQRPCClient, add_user_function_api_access_rights: None, logged_user: UserInfoDict, + other_logged_user: UserInfoDict, mock_function_factory: Callable[[FunctionClass], Function], osparc_product_name: ProductName, + access_by_other_user: bool, + check_write_permissions: bool, + expected_to_raise: type[Exception] | None, + status_or_output: str, ): # Register the function first registered_function = await functions_rpc.register_function( @@ -704,18 +718,54 @@ async def test_update_function_job_status( ) assert old_job_status.status == "created" - # Update the function job status - new_status = FunctionJobStatus(status="COMPLETED") - updated_job_status = await functions_rpc.update_function_job_status( + await functions_rpc.set_group_permissions( rabbitmq_rpc_client=rpc_client, - function_job_id=registered_job.uid, user_id=logged_user["id"], product_name=osparc_product_name, - job_status=new_status, + object_type="function_job", + object_ids=[registered_job.uid], + permission_group_id=int(other_logged_user["primary_gid"]), + read=True, ) - # Assert the updated job status matches the new status - assert updated_job_status == new_status + async def update_job_status_or_output(new_status, new_outputs): + if status_or_output == "status": + return await functions_rpc.update_function_job_status( + rabbitmq_rpc_client=rpc_client, + function_job_id=registered_job.uid, + user_id=( + other_logged_user["id"] + if access_by_other_user + else logged_user["id"] + ), + product_name=osparc_product_name, + job_status=new_status, + check_write_permissions=check_write_permissions, + ) + return await functions_rpc.update_function_job_outputs( + rabbitmq_rpc_client=rpc_client, + function_job_id=registered_job.uid, + user_id=( + other_logged_user["id"] if access_by_other_user else logged_user["id"] + ), + product_name=osparc_product_name, + outputs=new_outputs, + check_write_permissions=check_write_permissions, + ) + + # Update the function job status + new_status = FunctionJobStatus(status="COMPLETED") + new_outputs = {"output1": "new_result1", "output2": "new_result2"} + if expected_to_raise: + with pytest.raises(expected_to_raise): + await update_job_status_or_output(new_status, new_outputs) + return + + return_value = await update_job_status_or_output(new_status, new_outputs) + if status_or_output == "status": + assert return_value == new_status + else: + assert return_value == new_outputs @pytest.mark.parametrize(