diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index bd957858c493..29da07364b78 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -4,31 +4,23 @@ # pylint: disable=too-many-arguments -from enum import Enum from typing import Annotated -from _common import as_query from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.users import ( + MyFunctionPermissionsGet, MyPermissionGet, MyProfileGet, MyProfilePatch, MyTokenCreate, MyTokenGet, - UserAccountApprove, - UserAccountGet, - UserAccountReject, - UserAccountSearchQueryParams, UserGet, - UsersAccountListQueryParams, UsersSearch, ) from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope -from models_library.rest_pagination import Page from models_library.user_preferences import PreferenceIdentifier from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.users._common.schemas import PreRegisteredUserGet from simcore_service_webserver.users._notifications import ( UserNotification, UserNotificationCreate, @@ -128,6 +120,13 @@ async def mark_notification_as_read( async def list_user_permissions(): ... +@router.get( + "/me/function-permissions", + response_model=Envelope[MyFunctionPermissionsGet], +) +async def list_user_functions_permissions(): ... + + # # USERS public # @@ -139,56 +138,3 @@ async def list_user_permissions(): ... description="Search among users who are publicly visible to the caller (i.e., me) based on their privacy settings.", ) async def search_users(_body: UsersSearch): ... - - -# -# USERS admin -# - -_extra_tags: list[str | Enum] = ["admin"] - - -@router.get( - "/admin/user-accounts", - response_model=Page[UserAccountGet], - tags=_extra_tags, -) -async def list_users_accounts( - _query: Annotated[as_query(UsersAccountListQueryParams), Depends()], -): ... - - -@router.post( - "/admin/user-accounts:approve", - status_code=status.HTTP_204_NO_CONTENT, - tags=_extra_tags, -) -async def approve_user_account(_body: UserAccountApprove): ... - - -@router.post( - "/admin/user-accounts:reject", - status_code=status.HTTP_204_NO_CONTENT, - tags=_extra_tags, -) -async def reject_user_account(_body: UserAccountReject): ... - - -@router.get( - "/admin/user-accounts:search", - response_model=Envelope[list[UserAccountGet]], - tags=_extra_tags, -) -async def search_user_accounts( - _query: Annotated[UserAccountSearchQueryParams, Depends()], -): - # NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods - ... - - -@router.post( - "/admin/user-accounts:pre-register", - response_model=Envelope[UserAccountGet], - tags=_extra_tags, -) -async def pre_register_user_account(_body: PreRegisteredUserGet): ... diff --git a/api/specs/web-server/_users_admin.py b/api/specs/web-server/_users_admin.py new file mode 100644 index 000000000000..db25af3e5d76 --- /dev/null +++ b/api/specs/web-server/_users_admin.py @@ -0,0 +1,72 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from enum import Enum +from typing import Annotated + +from _common import as_query +from fastapi import APIRouter, Depends, status +from models_library.api_schemas_webserver.users import ( + UserAccountApprove, + UserAccountGet, + UserAccountReject, + UserAccountSearchQueryParams, + UsersAccountListQueryParams, +) +from models_library.generics import Envelope +from models_library.rest_pagination import Page +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.users._common.schemas import PreRegisteredUserGet + +router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) + +_extra_tags: list[str | Enum] = ["admin"] + + +@router.get( + "/admin/user-accounts", + response_model=Page[UserAccountGet], + tags=_extra_tags, +) +async def list_users_accounts( + _query: Annotated[as_query(UsersAccountListQueryParams), Depends()], +): ... + + +@router.post( + "/admin/user-accounts:approve", + status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, +) +async def approve_user_account(_body: UserAccountApprove): ... + + +@router.post( + "/admin/user-accounts:reject", + status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, +) +async def reject_user_account(_body: UserAccountReject): ... + + +@router.get( + "/admin/user-accounts:search", + response_model=Envelope[list[UserAccountGet]], + tags=_extra_tags, +) +async def search_user_accounts( + _query: Annotated[UserAccountSearchQueryParams, Depends()], +): + # NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods + ... + + +@router.post( + "/admin/user-accounts:pre-register", + response_model=Envelope[UserAccountGet], + tags=_extra_tags, +) +async def pre_register_user_account(_body: PreRegisteredUserGet): ... diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index 700bdc7d63c0..8a18b222071d 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -26,6 +26,7 @@ "_tags_groups", # after _tags "_products", "_users", + "_users_admin", # after _users "_wallets", # add-ons --- "_activity", diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index aea0fdfb0eb1..060441b1b43d 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -377,3 +377,7 @@ class MyPermissionGet(OutputSchema): @classmethod def from_domain_model(cls, permission: UserPermission) -> Self: return cls(name=permission.name, allowed=permission.allowed) + + +class MyFunctionPermissionsGet(OutputSchema): + write_functions: bool diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 7d530da816d7..106d4ac00050 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.68.1 +0.69.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 2a5d7a2e2098..19db53dcef80 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.68.1 +current_version = 0.69.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 73e8b2d4d7ee..d8e49bb3adf5 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.68.1 + version: 0.69.0 servers: - url: '' description: webserver @@ -1352,6 +1352,19 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_MyPermissionGet__' + /v0/me/function-permissions: + get: + tags: + - users + summary: List User Functions Permissions + operationId: list_user_functions_permissions + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_MyFunctionPermissionsGet_' /v0/users:search: post: tags: @@ -10213,6 +10226,19 @@ components: title: Error type: object title: Envelope[LoginNextPage] + Envelope_MyFunctionPermissionsGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/MyFunctionPermissionsGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[MyFunctionPermissionsGet] Envelope_MyGroupsGet_: properties: data: @@ -12642,6 +12668,15 @@ components: required: - color title: MarkerUI + MyFunctionPermissionsGet: + properties: + writeFunctions: + type: boolean + title: Writefunctions + type: object + required: + - writeFunctions + title: MyFunctionPermissionsGet MyGroupsGet: properties: me: 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 f844628b8d7b..9d263d58136d 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 @@ -5,6 +5,7 @@ RegisteredFunction, RegisteredFunctionGet, ) +from models_library.api_schemas_webserver.users import MyFunctionPermissionsGet from pydantic import TypeAdapter from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -100,3 +101,29 @@ async def delete_function(request: web.Request) -> web.Response: ) return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +# +# /me/* endpoints +# + + +@routes.get(f"/{VTAG}/me/function-permissions", name="list_user_functions_permissions") +@login_required +@handle_rest_requests_exceptions +async def list_user_functions_permissions(request: web.Request) -> web.Response: + req_ctx = AuthenticatedRequestContext.model_validate(request) + + function_permissions = ( + await _functions_service.get_functions_user_api_access_rights( + app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + ) + + assert function_permissions.user_id == req_ctx.user_id # nosec + + return envelope_json_response( + MyFunctionPermissionsGet(write_functions=function_permissions.write_functions) + ) diff --git a/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py b/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py index b1abcf124f4d..c9484cb224ae 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py @@ -4,6 +4,8 @@ from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack +from typing import Any from uuid import uuid4 import pytest @@ -16,6 +18,7 @@ ) from models_library.products import ProductName from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict +from pytest_simcore.helpers.postgres_tools import insert_and_get_row_lifespan from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict from servicelib.rabbitmq import RabbitMQRPCClient @@ -189,3 +192,35 @@ async def add_user_function_api_access_rights( funcapi_api_access_rights_table.c.group_id == group_id ) ) + + +@pytest.fixture +async def logged_user_function_api_access_rights( + asyncpg_engine: AsyncEngine, + logged_user: UserInfoDict, + *, + expected_write_functions: bool, +) -> AsyncIterator[dict[str, Any]]: + cm = insert_and_get_row_lifespan( + asyncpg_engine, + table=funcapi_api_access_rights_table, + values={ + "group_id": logged_user["primary_gid"], + "product_name": FRONTEND_APP_DEFAULT, + "read_functions": True, + "write_functions": expected_write_functions, + "execute_functions": True, + "read_function_jobs": True, + "write_function_jobs": True, + "execute_function_jobs": True, + "read_function_job_collections": True, + "write_function_job_collections": True, + "execute_function_job_collections": True, + }, + pk_col=funcapi_api_access_rights_table.c.group_id, + pk_value=logged_user["primary_gid"], + ) + + async with AsyncExitStack() as stack: + row = await stack.enter_async_context(cm) + yield row diff --git a/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py b/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py index ba50cd663d4a..198c9aa18488 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py @@ -17,6 +17,7 @@ JSONFunctionOutputSchema, RegisteredProjectFunctionGet, ) +from models_library.api_schemas_webserver.users import MyFunctionPermissionsGet from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status @@ -111,3 +112,25 @@ async def test_register_get_delete_function( ) response = await client.get(url) data, error = await assert_status(response, expected_get2) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +@pytest.mark.parametrize("expected_write_functions", [True, False]) +async def test_list_user_functions_permissions( + client: TestClient, + logged_user: UserInfoDict, + expected_write_functions: bool, + logged_user_function_api_access_rights: dict[str, Any], +): + assert ( + logged_user_function_api_access_rights["write_functions"] + == expected_write_functions + ) + + url = client.app.router["list_user_functions_permissions"].url_for() + response = await client.get(url) + data, error = await assert_status(response, expected_status_code=status.HTTP_200_OK) + + assert not error + function_permissions = MyFunctionPermissionsGet.model_validate(data) + assert function_permissions.write_functions == expected_write_functions