From 34374b4ac83285a7de3696d0e40fcd27c5633265 Mon Sep 17 00:00:00 2001 From: Giancarlo Romeo Date: Tue, 10 Dec 2024 10:13:43 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20API-keys=20?= =?UTF-8?q?service=20(#6843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: odeimaiz --- api/specs/web-server/_auth.py | 68 ------ api/specs/web-server/_auth_api_keys.py | 60 +++++ api/specs/web-server/openapi.py | 1 + .../api_schemas_webserver/auth.py | 79 +++++- .../src/models_library/rpc/__init__.py | 0 .../models_library/rpc/webserver/__init__.py | 0 .../rpc/webserver/auth/__init__.py | 0 .../rpc/webserver/auth/api_keys.py | 35 +++ .../rpc_interfaces/webserver/__init__.py | 0 .../rpc_interfaces/webserver/auth/__init__.py | 0 .../rpc_interfaces/webserver/auth/api_keys.py | 66 +++++ .../modules/osparc_variables/_api_auth.py | 11 +- .../modules/osparc_variables/_api_auth_rpc.py | 10 +- services/director-v2/tests/conftest.py | 6 +- .../source/class/osparc/data/Resources.js | 2 +- .../desktop/preferences/pages/TokensPage.js | 39 +-- .../desktop/preferences/window/ShowAPIKey.js | 51 ++-- .../api/v0/openapi.yaml | 221 ++++++++++++----- .../api_keys/_api.py | 117 --------- .../simcore_service_webserver/api_keys/_db.py | 155 ------------ .../api_keys/_exceptions_handlers.py | 26 ++ .../api_keys/_handlers.py | 81 ------ .../api_keys/_models.py | 11 + .../api_keys/_repository.py | 231 ++++++++++++++++++ .../api_keys/_rest.py | 112 +++++++++ .../api_keys/_rpc.py | 73 +++--- .../api_keys/_service.py | 123 ++++++++++ .../simcore_service_webserver/api_keys/api.py | 2 +- .../api_keys/errors.py | 13 + .../api_keys/plugin.py | 4 +- .../tests/unit/with_dbs/01/test_api_keys.py | 93 ++++--- .../unit/with_dbs/01/test_api_keys_rpc.py | 105 ++++---- tests/public-api/conftest.py | 18 +- 33 files changed, 1150 insertions(+), 663 deletions(-) create mode 100644 api/specs/web-server/_auth_api_keys.py create mode 100644 packages/models-library/src/models_library/rpc/__init__.py create mode 100644 packages/models-library/src/models_library/rpc/webserver/__init__.py create mode 100644 packages/models-library/src/models_library/rpc/webserver/auth/__init__.py create mode 100644 packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py delete mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_api.py delete mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_db.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py delete mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_handlers.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_models.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_repository.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_rest.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_service.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/errors.py diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index d47fe0aae5e..085e8d169c4 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -9,8 +9,6 @@ from fastapi import APIRouter, status from models_library.api_schemas_webserver.auth import ( AccountRequestInfo, - ApiKeyCreate, - ApiKeyGet, UnregisterCheck, ) from models_library.generics import Envelope @@ -264,72 +262,6 @@ async def email_confirmation(code: str): """email link sent to user to confirm an action""" -@router.get( - "/auth/api-keys", - operation_id="list_api_keys", - responses={ - status.HTTP_200_OK: { - "description": "returns the display names of API keys", - "model": list[str], - }, - status.HTTP_400_BAD_REQUEST: { - "description": "key name requested is invalid", - }, - status.HTTP_401_UNAUTHORIZED: { - "description": "requires login to list keys", - }, - status.HTTP_403_FORBIDDEN: { - "description": "not enough permissions to list keys", - }, - }, -) -async def list_api_keys(): - """lists display names of API keys by this user""" - - -@router.post( - "/auth/api-keys", - operation_id="create_api_key", - responses={ - status.HTTP_200_OK: { - "description": "Authorization granted returning API key", - "model": ApiKeyGet, - }, - status.HTTP_400_BAD_REQUEST: { - "description": "key name requested is invalid", - }, - status.HTTP_401_UNAUTHORIZED: { - "description": "requires login to list keys", - }, - status.HTTP_403_FORBIDDEN: { - "description": "not enough permissions to list keys", - }, - }, -) -async def create_api_key(_body: ApiKeyCreate): - """creates API keys to access public API""" - - -@router.delete( - "/auth/api-keys", - operation_id="delete_api_key", - status_code=status.HTTP_204_NO_CONTENT, - responses={ - status.HTTP_204_NO_CONTENT: { - "description": "api key successfully deleted", - }, - status.HTTP_401_UNAUTHORIZED: { - "description": "requires login to delete a key", - }, - status.HTTP_403_FORBIDDEN: { - "description": "not enough permissions to delete a key", - }, - }, -) -async def delete_api_key(_body: ApiKeyCreate): - """deletes API key by name""" - - @router.get( "/auth/captcha", operation_id="request_captcha", diff --git a/api/specs/web-server/_auth_api_keys.py b/api/specs/web-server/_auth_api_keys.py new file mode 100644 index 00000000000..0c6512eddda --- /dev/null +++ b/api/specs/web-server/_auth_api_keys.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from models_library.api_schemas_webserver.auth import ( + ApiKeyCreateRequest, + ApiKeyCreateResponse, + ApiKeyGet, +) +from models_library.generics import Envelope +from models_library.rest_error import EnvelopedError +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.api_keys._exceptions_handlers import _TO_HTTP_ERROR_MAP +from simcore_service_webserver.api_keys._rest import ApiKeysPathParams + +router = APIRouter( + prefix=f"/{API_VTAG}", + tags=["auth"], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, +) + + +@router.post( + "/auth/api-keys", + operation_id="create_api_key", + status_code=status.HTTP_201_CREATED, + response_model=Envelope[ApiKeyCreateResponse], +) +async def create_api_key(_body: ApiKeyCreateRequest): + """creates API keys to access public API""" + + +@router.get( + "/auth/api-keys", + operation_id="list_api_keys", + response_model=Envelope[list[ApiKeyGet]], + status_code=status.HTTP_200_OK, +) +async def list_api_keys(): + """lists API keys by this user""" + + +@router.get( + "/auth/api-keys/{api_key_id}", + operation_id="get_api_key", + response_model=Envelope[ApiKeyGet], + status_code=status.HTTP_200_OK, +) +async def get_api_key(_path: Annotated[ApiKeysPathParams, Depends()]): + """returns the API Key with the given ID""" + + +@router.delete( + "/auth/api-keys/{api_key_id}", + operation_id="delete_api_key", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_api_key(_path: Annotated[ApiKeysPathParams, Depends()]): + """deletes the API key with the given ID""" diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index 77e656efdaa..b5fcb5fcb63 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -20,6 +20,7 @@ # # core --- "_auth", + "_auth_api_keys", "_groups", "_tags", "_tags_groups", # after _tags diff --git a/packages/models-library/src/models_library/api_schemas_webserver/auth.py b/packages/models-library/src/models_library/api_schemas_webserver/auth.py index c841056d40c..6fa33b3fdc4 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/auth.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/auth.py @@ -1,10 +1,12 @@ from datetime import timedelta -from typing import Any +from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict, Field, SecretStr +from models_library.basic_types import IDStr +from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr +from pydantic.alias_generators import to_camel from ..emails import LowerCaseEmailStr -from ._base import InputSchema +from ._base import InputSchema, OutputSchema class AccountRequestInfo(InputSchema): @@ -51,42 +53,97 @@ class UnregisterCheck(InputSchema): # -class ApiKeyCreate(BaseModel): - display_name: str = Field(..., min_length=3) +class ApiKeyCreateRequest(InputSchema): + display_name: Annotated[str, Field(..., min_length=3)] expiration: timedelta | None = Field( None, description="Time delta from creation time to expiration. If None, then it does not expire.", ) model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=to_camel, + ), + from_attributes=True, + json_schema_extra={ + "examples": [ + { + "displayName": "test-api-forever", + }, + { + "displayName": "test-api-for-one-day", + "expiration": 60 * 60 * 24, + }, + { + "displayName": "test-api-for-another-day", + "expiration": "24:00:00", + }, + ] + }, + ) + + +class ApiKeyCreateResponse(OutputSchema): + id: IDStr + display_name: Annotated[str, Field(..., min_length=3)] + expiration: timedelta | None = Field( + None, + description="Time delta from creation time to expiration. If None, then it does not expire.", + ) + api_base_url: HttpUrl + api_key: str + api_secret: str + + model_config = ConfigDict( + alias_generator=AliasGenerator( + serialization_alias=to_camel, + ), + from_attributes=True, json_schema_extra={ "examples": [ { + "id": "42", "display_name": "test-api-forever", + "api_base_url": "http://api.osparc.io/v0", # NOSONAR + "api_key": "key", + "api_secret": "secret", }, { + "id": "48", "display_name": "test-api-for-one-day", "expiration": 60 * 60 * 24, + "api_base_url": "http://api.sim4life.io/v0", # NOSONAR + "api_key": "key", + "api_secret": "secret", }, { + "id": "54", "display_name": "test-api-for-another-day", "expiration": "24:00:00", + "api_base_url": "http://api.osparc-master.io/v0", # NOSONAR + "api_key": "key", + "api_secret": "secret", }, ] - } + }, ) -class ApiKeyGet(BaseModel): - display_name: str = Field(..., min_length=3) - api_key: str - api_secret: str +class ApiKeyGet(OutputSchema): + id: IDStr + display_name: Annotated[str, Field(..., min_length=3)] model_config = ConfigDict( + alias_generator=AliasGenerator( + serialization_alias=to_camel, + ), from_attributes=True, json_schema_extra={ "examples": [ - {"display_name": "myapi", "api_key": "key", "api_secret": "secret"}, + { + "id": "42", + "display_name": "myapi", + }, ] }, ) diff --git a/packages/models-library/src/models_library/rpc/__init__.py b/packages/models-library/src/models_library/rpc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/rpc/webserver/__init__.py b/packages/models-library/src/models_library/rpc/webserver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/rpc/webserver/auth/__init__.py b/packages/models-library/src/models_library/rpc/webserver/auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py b/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py new file mode 100644 index 00000000000..be36327abe7 --- /dev/null +++ b/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py @@ -0,0 +1,35 @@ +import datetime as dt +from typing import Annotated + +from models_library.basic_types import IDStr +from pydantic import BaseModel, ConfigDict, Field + + +class ApiKeyCreate(BaseModel): + display_name: Annotated[str, Field(..., min_length=3)] + expiration: dt.timedelta | None = None + + model_config = ConfigDict( + from_attributes=True, + ) + + +class ApiKeyGet(BaseModel): + id: IDStr + display_name: Annotated[str, Field(..., min_length=3)] + api_key: str | None = None + api_secret: str | None = None + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "examples": [ + { + "id": "42", + "display_name": "test-api-forever", + "api_key": "key", + "api_secret": "secret", + }, + ] + }, + ) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py new file mode 100644 index 00000000000..e70889e3de1 --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py @@ -0,0 +1,66 @@ +import logging + +from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE +from models_library.basic_types import IDStr +from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate, ApiKeyGet +from pydantic import TypeAdapter +from servicelib.logging_utils import log_decorator +from servicelib.rabbitmq import RabbitMQRPCClient + +_logger = logging.getLogger(__name__) + + +@log_decorator(_logger, level=logging.DEBUG) +async def create_api_key( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: str, + product_name: str, + api_key: ApiKeyCreate, +) -> ApiKeyGet: + result: ApiKeyGet = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("create_api_key"), + user_id=user_id, + product_name=product_name, + api_key=api_key, + ) + assert isinstance(result, ApiKeyGet) + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_api_key( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: str, + product_name: str, + api_key_id: IDStr, +) -> ApiKeyGet: + result: ApiKeyGet = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("get_api_key"), + user_id=user_id, + product_name=product_name, + api_key_id=api_key_id, + ) + assert isinstance(result, ApiKeyGet) + return result + + +async def delete_api_key( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: str, + product_name: str, + api_key_id: IDStr, +) -> None: + result = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("delete_api_key"), + user_id=user_id, + product_name=product_name, + api_key_id=api_key_id, + ) + assert result is None diff --git a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py index 65cb0934b35..a207df3aec3 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py @@ -4,8 +4,8 @@ from aiocache import cached # type: ignore[import-untyped] from fastapi import FastAPI -from models_library.api_schemas_webserver.auth import ApiKeyGet from models_library.products import ProductName +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from ._api_auth_rpc import get_or_create_api_key_and_secret @@ -30,10 +30,13 @@ async def _get_or_create_for( product_name: ProductName, user_id: UserID, ) -> ApiKeyGet: - - name = create_unique_api_name_for(product_name, user_id) + display_name = create_unique_api_name_for(product_name, user_id) return await get_or_create_api_key_and_secret( - app, product_name=product_name, user_id=user_id, name=name, expiration=None + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=None, ) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py index c9edc8c0f1c..1a6da1f7382 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py @@ -2,9 +2,9 @@ from fastapi import FastAPI from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE -from models_library.api_schemas_webserver.auth import ApiKeyGet from models_library.products import ProductName from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from pydantic import TypeAdapter @@ -20,16 +20,16 @@ async def get_or_create_api_key_and_secret( *, product_name: ProductName, user_id: UserID, - name: str, + display_name: str, expiration: timedelta | None = None, ) -> ApiKeyGet: rpc_client = get_rabbitmq_rpc_client(app) result = await rpc_client.request( WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("get_or_create_api_keys"), - product_name=product_name, + TypeAdapter(RPCMethodName).validate_python("get_or_create_api_key"), user_id=user_id, - name=name, + display_name=display_name, expiration=expiration, + product_name=product_name, ) return ApiKeyGet.model_validate(result) diff --git a/services/director-v2/tests/conftest.py b/services/director-v2/tests/conftest.py index 231debc371f..6337e7eea88 100644 --- a/services/director-v2/tests/conftest.py +++ b/services/director-v2/tests/conftest.py @@ -20,9 +20,9 @@ from asgi_lifespan import LifespanManager from faker import Faker from fastapi import FastAPI -from models_library.api_schemas_webserver.auth import ApiKeyGet from models_library.products import ProductName from models_library.projects import Node, NodesDict +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import ( @@ -347,7 +347,7 @@ async def _create( *, product_name: ProductName, user_id: UserID, - name: str, + display_name: str, expiration: timedelta, ): assert app @@ -355,7 +355,7 @@ async def _create( assert user_id assert expiration is None - fake_data.display_name = name + fake_data.display_name = display_name return fake_data # mocks RPC interface diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index de1d5aeddaa..b1c1d81ffa6 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -767,7 +767,7 @@ qx.Class.define("osparc.data.Resources", { }, delete: { method: "DELETE", - url: statics.API + "/auth/api-keys" + url: statics.API + "/auth/api-keys/{apiKeyId}" } } }, diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js index ddc9d94f60f..6d5ae0e5258 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js @@ -77,7 +77,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { const formData = e.getData(); const params = { data: { - "display_name": formData["name"] + "displayName": formData["name"] } }; if (formData["expiration"]) { @@ -90,14 +90,17 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { .then(data => { this.__rebuildAPIKeysList(); - const key = data["api_key"]; - const secret = data["api_secret"]; - const showAPIKeyWindow = new osparc.desktop.preferences.window.ShowAPIKey(key, secret); + const key = data["apiKey"]; + const secret = data["apiSecret"]; + const baseUrl = data["apiBaseUrl"]; + const showAPIKeyWindow = new osparc.desktop.preferences.window.ShowAPIKey(key, secret, baseUrl); showAPIKeyWindow.center(); showAPIKeyWindow.open(); }) .catch(err => { - osparc.FlashMessenger.getInstance().logAs(err.message, "ERROR"); + const errorMsg = err.message || this.tr("Cannot create API Key"); + osparc.FlashMessenger.getInstance().logAs(errorMsg, "ERROR"); + console.error(err); }) .finally(() => this.__requestAPIKeyBtn.setFetching(false)); }, this); @@ -109,26 +112,24 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { osparc.data.Resources.get("apiKeys") .then(apiKeys => { apiKeys.forEach(apiKey => { - const apiKeyForm = this.__createValidAPIKeyForm(apiKey); + const apiKeyForm = this.__createAPIKeyEntry(apiKey); this.__apiKeysList.add(apiKeyForm); }); }) .catch(err => console.error(err)); }, - __createValidAPIKeyForm: function(apiKeyLabel) { + __createAPIKeyEntry: function(apiKey) { const grid = this.__createValidEntryLayout(); - const nameLabel = new qx.ui.basic.Label(apiKeyLabel); + const nameLabel = new qx.ui.basic.Label(apiKey["displayName"]); grid.add(nameLabel, { row: 0, column: 0 }); const delAPIKeyBtn = new qx.ui.form.Button(null, "@FontAwesome5Solid/trash/14"); - delAPIKeyBtn.addListener("execute", e => { - this.__deleteAPIKey(apiKeyLabel); - }, this); + delAPIKeyBtn.addListener("execute", () => this.__deleteAPIKey(apiKey["id"]), this); grid.add(delAPIKeyBtn, { row: 0, column: 1 @@ -137,7 +138,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { return grid; }, - __deleteAPIKey: function(apiKeyLabel) { + __deleteAPIKey: function(apiKeyId) { if (!osparc.data.Permissions.getInstance().canDo("user.apikey.delete", true)) { return; } @@ -153,13 +154,17 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { win.addListener("close", () => { if (win.getConfirmed()) { const params = { - data: { - "display_name": apiKeyLabel + url: { + "apiKeyId": apiKeyId } }; osparc.data.Resources.fetch("apiKeys", "delete", params) .then(() => this.__rebuildAPIKeysList()) - .catch(err => console.error(err)); + .catch(err => { + const errorMsg = err.message || this.tr("Cannot delete API Key"); + osparc.FlashMessenger.getInstance().logAs(errorMsg, "ERROR"); + console.error(err) + }); } }, this); }, @@ -203,7 +208,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { const supportedExternalServices = osparc.utils.Utils.deepCloneObject(this.__supportedExternalServices()); tokensList.forEach(token => { - const tokenForm = this.__createValidTokenEntry(token); + const tokenForm = this.__createTokenEntry(token); this.__validTokensGB.add(tokenForm); const idx = supportedExternalServices.findIndex(srv => srv.name === token.service); if (idx > -1) { @@ -244,7 +249,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { .catch(err => console.error(err)); }, - __createValidTokenEntry: function(token) { + __createTokenEntry: function(token) { const grid = this.__createValidEntryLayout(); const service = token["service"]; diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js index 4c63579a70f..6c7209d3ecc 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js +++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js @@ -16,7 +16,7 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", { extend: osparc.desktop.preferences.window.APIKeyBase, - construct: function(key, secret) { + construct: function(key, secret, baseUrl) { const caption = this.tr("API Key"); const infoText = this.tr("For your protection, store your access keys securely and do not share them. You will not be able to access the key again once this window is closed."); this.base(arguments, caption, infoText); @@ -25,39 +25,60 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", { clickAwayClose: false }); - this.__populateTokens(key, secret); + this.__populateTokens(key, secret, baseUrl); }, members: { - __populateTokens: function(key, secret) { - const hBox1 = this.__createEntry(this.tr("Key:"), key); + __populateTokens: function(key, secret, baseUrl) { + const hBox1 = this.__createStarredEntry(this.tr("Key:"), key); this._add(hBox1); - const hBox2 = this.__createEntry(this.tr("Secret:"), secret); + const hBox2 = this.__createStarredEntry(this.tr("Secret:"), secret); this._add(hBox2); - const hBox3 = new qx.ui.container.Composite(new qx.ui.layout.HBox(10)).set({ + const hBox3 = this.__createEntry(this.tr("Base url:"), baseUrl); + this._add(hBox3); + + const buttonsLayout = new qx.ui.container.Composite(new qx.ui.layout.HBox(10)).set({ appearance: "margined-layout" }); - const copyAPIKeyBtn = new qx.ui.form.Button(this.tr("Copy API Key")); + const copyAPIKeyBtn = new qx.ui.form.Button(this.tr("API Key"), "@FontAwesome5Solid/copy/12"); copyAPIKeyBtn.addListener("execute", e => { if (osparc.utils.Utils.copyTextToClipboard(key)) { copyAPIKeyBtn.setIcon("@FontAwesome5Solid/check/12"); } }); - hBox3.add(copyAPIKeyBtn, { - width: "50%" + buttonsLayout.add(copyAPIKeyBtn, { + flex: 1 }); - const copyAPISecretBtn = new qx.ui.form.Button(this.tr("Copy API Secret")); + const copyAPISecretBtn = new qx.ui.form.Button(this.tr("API Secret"), "@FontAwesome5Solid/copy/12"); copyAPISecretBtn.addListener("execute", e => { if (osparc.utils.Utils.copyTextToClipboard(secret)) { copyAPISecretBtn.setIcon("@FontAwesome5Solid/check/12"); } }); - hBox3.add(copyAPISecretBtn, { - width: "50%" + buttonsLayout.add(copyAPISecretBtn, { + flex: 1 }); - this._add(hBox3); + const copyBaseUrlBtn = new qx.ui.form.Button(this.tr("Base URL"), "@FontAwesome5Solid/copy/12"); + copyBaseUrlBtn.addListener("execute", e => { + if (osparc.utils.Utils.copyTextToClipboard(baseUrl)) { + copyBaseUrlBtn.setIcon("@FontAwesome5Solid/check/12"); + } + }); + buttonsLayout.add(copyBaseUrlBtn, { + flex: 1 + }); + this._add(buttonsLayout); + }, + + __createStarredEntry: function(title, label) { + const hBox = this.__createEntry(title); + if (label) { + // partially hide the key and secret + hBox.getChildren()[1].setValue(label.substring(1, 8) + "****") + } + return hBox; }, __createEntry: function(title, label) { @@ -66,13 +87,13 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", { }); const sTitle = new qx.ui.basic.Label(title).set({ rich: true, - width: 40 + width: 60 }); hBox.add(sTitle); const sLabel = new qx.ui.basic.Label(); if (label) { // partially hide the key and secret - sLabel.setValue(label.substring(1, 8) + "****") + sLabel.setValue(label); } hBox.add(sLabel); return hBox; 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 0ab1f87a5c1..db975aa23c3 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 @@ -369,29 +369,39 @@ paths: $ref: '#/components/schemas/Envelope_Log_' 3XX: description: redirection to specific ui application page + /v0/auth/captcha: + get: + tags: + - auth + summary: Request Captcha + operationId: request_captcha + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + image/png: {} /v0/auth/api-keys: get: tags: - auth summary: List Api Keys - description: lists display names of API keys by this user + description: lists API keys by this user operationId: list_api_keys responses: '200': - description: returns the display names of API keys + description: Successful Response content: application/json: schema: - items: - type: string - type: array - title: Response 200 List Api Keys - '400': - description: key name requested is invalid - '401': - description: requires login to list keys - '403': - description: not enough permissions to list keys + $ref: '#/components/schemas/Envelope_list_ApiKeyGet__' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' post: tags: - auth @@ -402,53 +412,74 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ApiKeyCreate' + $ref: '#/components/schemas/ApiKeyCreateRequest' required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + /v0/auth/api-keys/{api_key_id}: + get: + tags: + - auth + summary: Get Api Key + description: returns the API Key with the given ID + operationId: get_api_key + parameters: + - name: api_key_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + title: Api Key Id responses: '200': - description: Authorization granted returning API key + description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/ApiKeyGet' - '400': - description: key name requested is invalid - '401': - description: requires login to list keys - '403': - description: not enough permissions to list keys + $ref: '#/components/schemas/Envelope_ApiKeyGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found delete: tags: - auth summary: Delete Api Key - description: deletes API key by name + description: deletes the API key with the given ID operationId: delete_api_key - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyCreate' + parameters: + - name: api_key_id + in: path required: true + schema: + type: string + minLength: 1 + maxLength: 100 + title: Api Key Id responses: '204': - description: api key successfully deleted - '401': - description: requires login to delete a key - '403': - description: not enough permissions to delete a key - /v0/auth/captcha: - get: - tags: - - auth - summary: Request Captcha - operationId: request_captcha - responses: - '200': description: Successful Response + '404': content: application/json: - schema: {} - image/png: {} + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found /v0/groups: get: tags: @@ -6714,12 +6745,12 @@ components: - link - widgets title: Announcement - ApiKeyCreate: + ApiKeyCreateRequest: properties: - display_name: + displayName: type: string minLength: 3 - title: Display Name + title: Displayname expiration: anyOf: - type: string @@ -6730,25 +6761,59 @@ components: it does not expire. type: object required: - - display_name - title: ApiKeyCreate - ApiKeyGet: + - displayName + title: ApiKeyCreateRequest + ApiKeyCreateResponse: properties: - display_name: + displayName: type: string minLength: 3 - title: Display Name - api_key: + title: Displayname + expiration: + anyOf: + - type: string + format: duration + - type: 'null' + title: Expiration + description: Time delta from creation time to expiration. If None, then + it does not expire. + id: + type: string + maxLength: 100 + minLength: 1 + title: Id + apiBaseUrl: + type: string + title: Apibaseurl + apiKey: type: string - title: Api Key - api_secret: + title: Apikey + apiSecret: + type: string + title: Apisecret + type: object + required: + - displayName + - id + - apiBaseUrl + - apiKey + - apiSecret + title: ApiKeyCreateResponse + ApiKeyGet: + properties: + id: + type: string + maxLength: 100 + minLength: 1 + title: Id + displayName: type: string - title: Api Secret + minLength: 3 + title: Displayname type: object required: - - display_name - - api_key - - api_secret + - id + - displayName title: ApiKeyGet AppStatusCheck: properties: @@ -7575,6 +7640,32 @@ components: title: Error type: object title: Envelope[AnyUrl] + Envelope_ApiKeyCreateResponse_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/ApiKeyCreateResponse' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[ApiKeyCreateResponse] + Envelope_ApiKeyGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/ApiKeyGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[ApiKeyGet] Envelope_AppStatusCheck_: properties: data: @@ -8536,6 +8627,22 @@ components: title: Error type: object title: Envelope[list[Announcement]] + Envelope_list_ApiKeyGet__: + properties: + data: + anyOf: + - items: + $ref: '#/components/schemas/ApiKeyGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[ApiKeyGet]] Envelope_list_DatasetMetaData__: properties: data: diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_api.py b/services/web/server/src/simcore_service_webserver/api_keys/_api.py deleted file mode 100644 index 6cdc15e2f24..00000000000 --- a/services/web/server/src/simcore_service_webserver/api_keys/_api.py +++ /dev/null @@ -1,117 +0,0 @@ -import re -import string -from datetime import timedelta -from typing import Final - -from aiohttp import web -from models_library.api_schemas_webserver.auth import ApiKeyCreate, ApiKeyGet -from models_library.products import ProductName -from models_library.users import UserID -from servicelib.utils_secrets import generate_token_secret_key - -from ._db import ApiKeyRepo - -_PUNCTUATION_REGEX = re.compile( - pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]" -) - -_KEY_LEN: Final = 10 -_SECRET_LEN: Final = 20 - - -async def list_api_keys( - app: web.Application, - *, - user_id: UserID, - product_name: ProductName, -) -> list[str]: - repo = ApiKeyRepo.create_from_app(app) - names: list[str] = await repo.list_names(user_id=user_id, product_name=product_name) - return names - - -def _generate_api_key_and_secret(name: str): - prefix = _PUNCTUATION_REGEX.sub("_", name[:5]) - api_key = f"{prefix}_{generate_token_secret_key(_KEY_LEN)}" - api_secret = generate_token_secret_key(_SECRET_LEN) - return api_key, api_secret - - -async def create_api_key( - app: web.Application, - *, - new: ApiKeyCreate, - user_id: UserID, - product_name: ProductName, -) -> ApiKeyGet: - # generate key and secret - api_key, api_secret = _generate_api_key_and_secret(new.display_name) - - # raises if name exists already! - repo = ApiKeyRepo.create_from_app(app) - await repo.create( - user_id=user_id, - product_name=product_name, - display_name=new.display_name, - expiration=new.expiration, - api_key=api_key, - api_secret=api_secret, - ) - - return ApiKeyGet( - display_name=new.display_name, - api_key=api_key, - api_secret=api_secret, - ) - - -async def get_api_key( - app: web.Application, *, name: str, user_id: UserID, product_name: ProductName -) -> ApiKeyGet | None: - repo = ApiKeyRepo.create_from_app(app) - row = await repo.get(display_name=name, user_id=user_id, product_name=product_name) - return ApiKeyGet.model_validate(row) if row else None - - -async def get_or_create_api_key( - app: web.Application, - *, - name: str, - user_id: UserID, - product_name: ProductName, - expiration: timedelta | None = None, -) -> ApiKeyGet: - - api_key, api_secret = _generate_api_key_and_secret(name) - - repo = ApiKeyRepo.create_from_app(app) - row = await repo.get_or_create( - user_id=user_id, - product_name=product_name, - display_name=name, - expiration=expiration, - api_key=api_key, - api_secret=api_secret, - ) - return ApiKeyGet.model_construct( - display_name=row.display_name, api_key=row.api_key, api_secret=row.api_secret - ) - - -async def delete_api_key( - app: web.Application, - *, - name: str, - user_id: UserID, - product_name: ProductName, -) -> None: - repo = ApiKeyRepo.create_from_app(app) - await repo.delete_by_name( - display_name=name, user_id=user_id, product_name=product_name - ) - - -async def prune_expired_api_keys(app: web.Application) -> list[str]: - repo = ApiKeyRepo.create_from_app(app) - names: list[str] = await repo.prune_expired() - return names diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_db.py b/services/web/server/src/simcore_service_webserver/api_keys/_db.py deleted file mode 100644 index ec08ce5dd67..00000000000 --- a/services/web/server/src/simcore_service_webserver/api_keys/_db.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging -from dataclasses import dataclass -from datetime import timedelta - -import sqlalchemy as sa -from aiohttp import web -from aiopg.sa.engine import Engine -from aiopg.sa.result import ResultProxy, RowProxy -from models_library.api_schemas_api_server.api_keys import ApiKeyInDB -from models_library.basic_types import IdInt -from models_library.products import ProductName -from models_library.users import UserID -from simcore_postgres_database.models.api_keys import api_keys -from sqlalchemy.dialects.postgresql import insert as pg_insert - -from ..db.plugin import get_database_engine - -_logger = logging.getLogger(__name__) - - -@dataclass -class ApiKeyRepo: - engine: Engine - - @classmethod - def create_from_app(cls, app: web.Application): - return cls(engine=get_database_engine(app)) - - async def list_names( - self, *, user_id: UserID, product_name: ProductName - ) -> list[str]: - async with self.engine.acquire() as conn: - stmt = sa.select(api_keys.c.display_name).where( - (api_keys.c.user_id == user_id) - & (api_keys.c.product_name == product_name) - ) - - result: ResultProxy = await conn.execute(stmt) - rows = await result.fetchall() or [] - return [r.display_name for r in rows] - - async def create( - self, - *, - user_id: UserID, - product_name: ProductName, - display_name: str, - expiration: timedelta | None, - api_key: str, - api_secret: str, - ) -> list[IdInt]: - async with self.engine.acquire() as conn: - stmt = ( - api_keys.insert() - .values( - display_name=display_name, - user_id=user_id, - product_name=product_name, - api_key=api_key, - api_secret=api_secret, - expires_at=(sa.func.now() + expiration) if expiration else None, - ) - .returning(api_keys.c.id) - ) - - result: ResultProxy = await conn.execute(stmt) - rows = await result.fetchall() or [] - return [r.id for r in rows] - - async def get( - self, *, display_name: str, user_id: UserID, product_name: ProductName - ) -> ApiKeyInDB | None: - async with self.engine.acquire() as conn: - stmt = sa.select(api_keys).where( - (api_keys.c.user_id == user_id) - & (api_keys.c.display_name == display_name) - & (api_keys.c.product_name == product_name) - ) - - result: ResultProxy = await conn.execute(stmt) - row: RowProxy | None = await result.fetchone() - return ApiKeyInDB.model_validate(row) if row else None - - async def get_or_create( - self, - *, - user_id: UserID, - product_name: ProductName, - display_name: str, - expiration: timedelta | None, - api_key: str, - api_secret: str, - ) -> ApiKeyInDB: - async with self.engine.acquire() as conn: - # Implemented as "create or get" - insert_stmt = ( - pg_insert(api_keys) - .values( - display_name=display_name, - user_id=user_id, - product_name=product_name, - api_key=api_key, - api_secret=api_secret, - expires_at=(sa.func.now() + expiration) if expiration else None, - ) - .on_conflict_do_update( - index_elements=["user_id", "display_name"], - set_={ - "product_name": product_name - }, # dummy enable returning since on_conflict_do_nothing returns None - # NOTE: use this entry for reference counting in https://github.com/ITISFoundation/osparc-simcore/issues/5875 - ) - .returning(api_keys) - ) - - result = await conn.execute(insert_stmt) - row = await result.fetchone() - assert row # nosec - return ApiKeyInDB.model_validate(row) - - async def delete_by_name( - self, *, display_name: str, user_id: UserID, product_name: ProductName - ) -> None: - async with self.engine.acquire() as conn: - stmt = api_keys.delete().where( - (api_keys.c.user_id == user_id) - & (api_keys.c.display_name == display_name) - & (api_keys.c.product_name == product_name) - ) - await conn.execute(stmt) - - async def delete_by_key( - self, *, api_key: str, user_id: UserID, product_name: ProductName - ) -> None: - async with self.engine.acquire() as conn: - stmt = api_keys.delete().where( - (api_keys.c.user_id == user_id) - & (api_keys.c.api_key == api_key) - & (api_keys.c.product_name == product_name) - ) - await conn.execute(stmt) - - async def prune_expired(self) -> list[str]: - async with self.engine.acquire() as conn: - stmt = ( - api_keys.delete() - .where( - (api_keys.c.expires_at.is_not(None)) - & (api_keys.c.expires_at < sa.func.now()) - ) - .returning(api_keys.c.display_name) - ) - result: ResultProxy = await conn.execute(stmt) - rows = await result.fetchall() or [] - return [r.display_name for r in rows] diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py new file mode 100644 index 00000000000..4cb6c84f824 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py @@ -0,0 +1,26 @@ +from servicelib.aiohttp import status + +from ..exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from .errors import ApiKeyDuplicatedDisplayNameError, ApiKeyNotFoundError + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + ApiKeyDuplicatedDisplayNameError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "API key display name duplicated", + ), + ApiKeyNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "API key was not found", + ), +} + + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) +# this is one decorator with a single exception handler diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py deleted file mode 100644 index 4a7a84fe742..00000000000 --- a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging - -from aiohttp import web -from aiohttp.web import RouteTableDef -from models_library.api_schemas_webserver.auth import ApiKeyCreate -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import parse_request_body_as -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from simcore_postgres_database.errors import DatabaseError - -from .._meta import API_VTAG -from ..login.decorators import login_required -from ..models import RequestContext -from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response -from . import _api - -_logger = logging.getLogger(__name__) - - -routes = RouteTableDef() - - -@routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys") -@login_required -@permission_required("user.apikey.*") -async def list_api_keys(request: web.Request): - req_ctx = RequestContext.model_validate(request) - api_keys_names = await _api.list_api_keys( - request.app, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) - return envelope_json_response(api_keys_names) - - -@routes.post(f"/{API_VTAG}/auth/api-keys", name="create_api_key") -@login_required -@permission_required("user.apikey.*") -async def create_api_key(request: web.Request): - req_ctx = RequestContext.model_validate(request) - new = await parse_request_body_as(ApiKeyCreate, request) - try: - data = await _api.create_api_key( - request.app, - new=new, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) - except DatabaseError as err: - raise web.HTTPBadRequest( - reason="Invalid API key name: already exists", - content_type=MIMETYPE_APPLICATION_JSON, - ) from err - - return envelope_json_response(data) - - -@routes.delete(f"/{API_VTAG}/auth/api-keys", name="delete_api_key") -@login_required -@permission_required("user.apikey.*") -async def delete_api_key(request: web.Request): - req_ctx = RequestContext.model_validate(request) - - # NOTE: SEE https://github.com/ITISFoundation/osparc-simcore/issues/4920 - body = await request.json() - name = body.get("display_name") - - try: - await _api.delete_api_key( - request.app, - name=name, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) - except DatabaseError as err: - _logger.warning( - "Failed to delete API key %s. Ignoring error", name, exc_info=err - ) - - return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_models.py b/services/web/server/src/simcore_service_webserver/api_keys/_models.py new file mode 100644 index 00000000000..ee0ecec85c2 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_models.py @@ -0,0 +1,11 @@ +import datetime as dt +from dataclasses import dataclass + + +@dataclass +class ApiKey: + id: str + display_name: str + expiration: dt.timedelta | None = None + api_key: str | None = None + api_secret: str | None = None diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_repository.py b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py new file mode 100644 index 00000000000..1f4a8dbdc79 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py @@ -0,0 +1,231 @@ +import logging +from datetime import timedelta + +import sqlalchemy as sa +from aiohttp import web +from asyncpg.exceptions import UniqueViolationError +from models_library.products import ProductName +from models_library.users import UserID +from simcore_postgres_database.models.api_keys import api_keys +from simcore_postgres_database.utils_repos import transaction_context +from simcore_service_webserver.api_keys._models import ApiKey +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.plugin import get_asyncpg_engine +from .errors import ApiKeyDuplicatedDisplayNameError + +_logger = logging.getLogger(__name__) + + +async def create_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + display_name: str, + expiration: timedelta | None, + api_key: str, + api_secret: str, +) -> ApiKey: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + try: + stmt = ( + api_keys.insert() + .values( + display_name=display_name, + user_id=user_id, + product_name=product_name, + api_key=api_key, + api_secret=api_secret, + expires_at=(sa.func.now() + expiration) if expiration else None, + ) + .returning(api_keys.c.id) + ) + + result = await conn.stream(stmt) + row = await result.first() + + return ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=display_name, + expiration=expiration, + api_key=api_key, + api_secret=api_secret, + ) + except UniqueViolationError as exc: + raise ApiKeyDuplicatedDisplayNameError(display_name=display_name) from exc + + +async def get_or_create_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + display_name: str, + expiration: timedelta | None, + api_key: str, + api_secret: str, +) -> ApiKey: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + # Implemented as "create or get" + insert_stmt = ( + pg_insert(api_keys) + .values( + display_name=display_name, + user_id=user_id, + product_name=product_name, + api_key=api_key, + api_secret=api_secret, + expires_at=(sa.func.now() + expiration) if expiration else None, + ) + .on_conflict_do_update( + index_elements=["user_id", "display_name"], + set_={ + "product_name": product_name + }, # dummy enable returning since on_conflict_do_nothing returns None + # NOTE: use this entry for reference counting in https://github.com/ITISFoundation/osparc-simcore/issues/5875 + ) + .returning(api_keys) + ) + + result = await conn.stream(insert_stmt) + row = await result.first() + assert row # nosec + + return ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=row.display_name, + expiration=row.expires_at, + api_key=row.api_key, + api_secret=row.api_secret, + ) + + +async def list_api_keys( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, +) -> list[ApiKey]: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = sa.select(api_keys.c.id, api_keys.c.display_name).where( + (api_keys.c.user_id == user_id) & (api_keys.c.product_name == product_name) + ) + + result = await conn.stream(stmt) + rows = [row async for row in result] + + return [ + ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=row.display_name, + ) + for row in rows + ] + + +async def get_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + api_key_id: str, + user_id: UserID, + product_name: ProductName, +) -> ApiKey | None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = sa.select(api_keys).where( + ( + api_keys.c.id == int(api_key_id) + ) # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + & (api_keys.c.user_id == user_id) + & (api_keys.c.product_name == product_name) + ) + + result = await conn.stream(stmt) + row = await result.first() + + return ( + ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=row.display_name, + expiration=row.expires_at, + api_key=row.api_key, + api_secret=row.api_secret, + ) + if row + else None + ) + + +async def delete_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + api_key_id: str, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = api_keys.delete().where( + ( + api_keys.c.id == int(api_key_id) + ) # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + & (api_keys.c.user_id == user_id) + & (api_keys.c.product_name == product_name) + ) + await conn.execute(stmt) + + +async def delete_by_name( + app: web.Application, + connection: AsyncConnection | None = None, + *, + display_name: str, + user_id: UserID, + product_name: ProductName, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = api_keys.delete().where( + (api_keys.c.user_id == user_id) + & (api_keys.c.display_name == display_name) + & (api_keys.c.product_name == product_name) + ) + await conn.execute(stmt) + + +async def delete_by_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + api_key: str, + user_id: UserID, + product_name: ProductName, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = api_keys.delete().where( + (api_keys.c.user_id == user_id) + & (api_keys.c.api_key == api_key) + & (api_keys.c.product_name == product_name) + ) + await conn.execute(stmt) + + +async def prune_expired( + app: web.Application, connection: AsyncConnection | None = None +) -> list[str]: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = ( + api_keys.delete() + .where( + (api_keys.c.expires_at.is_not(None)) + & (api_keys.c.expires_at < sa.func.now()) + ) + .returning(api_keys.c.display_name) + ) + result = await conn.stream(stmt) + rows = [row async for row in result] + return [r.display_name for r in rows] diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_rest.py b/services/web/server/src/simcore_service_webserver/api_keys/_rest.py new file mode 100644 index 00000000000..c3b81c63cd3 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_rest.py @@ -0,0 +1,112 @@ +import logging +from dataclasses import asdict + +from aiohttp import web +from aiohttp.web import RouteTableDef +from models_library.api_schemas_webserver.auth import ( + ApiKeyCreateRequest, + ApiKeyCreateResponse, + ApiKeyGet, +) +from models_library.basic_types import IDStr +from models_library.rest_base import StrictRequestParameters +from pydantic import TypeAdapter +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) + +from .._meta import API_VTAG +from ..login.decorators import login_required +from ..models import RequestContext +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from . import _service +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import ApiKey + +_logger = logging.getLogger(__name__) + + +routes = RouteTableDef() + + +class ApiKeysPathParams(StrictRequestParameters): + api_key_id: IDStr + + +@routes.post(f"/{API_VTAG}/auth/api-keys", name="create_api_key") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def create_api_key(request: web.Request): + req_ctx = RequestContext.model_validate(request) + new_api_key = await parse_request_body_as(ApiKeyCreateRequest, request) + + created_api_key: ApiKey = await _service.create_api_key( + request.app, + display_name=new_api_key.display_name, + expiration=new_api_key.expiration, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + + api_key = ApiKeyCreateResponse.model_validate( + { + **asdict(created_api_key), + "api_base_url": "http://localhost:8000", + } # TODO: https://github.com/ITISFoundation/osparc-simcore/issues/6340 # @pcrespov + ) + + return envelope_json_response(api_key) + + +@routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def list_api_keys(request: web.Request): + req_ctx = RequestContext.model_validate(request) + api_keys = await _service.list_api_keys( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + return envelope_json_response( + TypeAdapter(list[ApiKeyGet]).validate_python(api_keys) + ) + + +@routes.get(f"/{API_VTAG}/auth/api-keys/{{api_key_id}}", name="get_api_key") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def get_api_key(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(ApiKeysPathParams, request) + api_key: ApiKey = await _service.get_api_key( + request.app, + api_key_id=path_params.api_key_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + return envelope_json_response(ApiKeyGet.model_validate(api_key)) + + +@routes.delete(f"/{API_VTAG}/auth/api-keys/{{api_key_id}}", name="delete_api_key") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def delete_api_key(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(ApiKeysPathParams, request) + + await _service.delete_api_key( + request.app, + api_key_id=path_params.api_key_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py b/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py index d54b24c1667..3dd04600cfa 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py @@ -2,71 +2,88 @@ from aiohttp import web from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE -from models_library.api_schemas_webserver.auth import ApiKeyCreate, ApiKeyGet +from models_library.api_schemas_webserver.auth import ApiKeyCreateRequest from models_library.products import ProductName +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from servicelib.rabbitmq import RPCRouter from ..rabbitmq import get_rabbitmq_rpc_server -from . import _api +from . import _service +from ._models import ApiKey +from .errors import ApiKeyNotFoundError router = RPCRouter() @router.expose() -async def create_api_keys( +async def create_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - new: ApiKeyCreate, + product_name: ProductName, + api_key: ApiKeyCreateRequest, ) -> ApiKeyGet: - return await _api.create_api_key( - app, new=new, user_id=user_id, product_name=product_name + created_api_key: ApiKey = await _service.create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=api_key.display_name, + expiration=api_key.expiration, ) + return ApiKeyGet.model_validate(created_api_key) -@router.expose() -async def delete_api_keys( + +@router.expose(reraise_if_error_type=(ApiKeyNotFoundError,)) +async def get_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - name: str, -) -> None: - await _api.delete_api_key( - app, name=name, user_id=user_id, product_name=product_name + product_name: ProductName, + api_key_id: str, +) -> ApiKeyGet: + api_key: ApiKey = await _service.get_api_key( + app, + user_id=user_id, + product_name=product_name, + api_key_id=api_key_id, ) + return ApiKeyGet.model_validate(api_key) @router.expose() -async def api_key_get( +async def get_or_create_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - name: str, -) -> ApiKeyGet | None: - return await _api.get_api_key( - app, name=name, user_id=user_id, product_name=product_name + product_name: ProductName, + display_name: str, + expiration: timedelta | None = None, +) -> ApiKeyGet: + api_key: ApiKey = await _service.get_or_create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=expiration, ) + return ApiKeyGet.model_validate(api_key) @router.expose() -async def get_or_create_api_keys( +async def delete_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - name: str, - expiration: timedelta | None = None, -) -> ApiKeyGet: - return await _api.get_or_create_api_key( + product_name: ProductName, + api_key_id: str, +) -> None: + await _service.delete_api_key( app, - name=name, user_id=user_id, product_name=product_name, - expiration=expiration, + api_key_id=api_key_id, ) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_service.py b/services/web/server/src/simcore_service_webserver/api_keys/_service.py new file mode 100644 index 00000000000..4d7cdcb43dc --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_service.py @@ -0,0 +1,123 @@ +import datetime as dt +import re +import string +from typing import Final + +from aiohttp import web +from models_library.products import ProductName +from models_library.users import UserID +from servicelib.utils_secrets import generate_token_secret_key + +from . import _repository +from ._models import ApiKey +from .errors import ApiKeyNotFoundError + +_PUNCTUATION_REGEX = re.compile( + pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]" +) + +_KEY_LEN: Final = 10 +_SECRET_LEN: Final = 20 + + +def _generate_api_key_and_secret(name: str): + prefix = _PUNCTUATION_REGEX.sub("_", name[:5]) + api_key = f"{prefix}_{generate_token_secret_key(_KEY_LEN)}" + api_secret = generate_token_secret_key(_SECRET_LEN) + return api_key, api_secret + + +async def create_api_key( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + display_name=str, + expiration=dt.timedelta, +) -> ApiKey: + api_key, api_secret = _generate_api_key_and_secret(display_name) + + return await _repository.create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=expiration, + api_key=api_key, + api_secret=api_secret, + ) + + +async def list_api_keys( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, +) -> list[ApiKey]: + api_keys: list[ApiKey] = await _repository.list_api_keys( + app, user_id=user_id, product_name=product_name + ) + return api_keys + + +async def get_api_key( + app: web.Application, + *, + api_key_id: str, + user_id: UserID, + product_name: ProductName, +) -> ApiKey: + api_key: ApiKey | None = await _repository.get_api_key( + app, + api_key_id=api_key_id, + user_id=user_id, + product_name=product_name, + ) + if api_key is not None: + return api_key + + raise ApiKeyNotFoundError(api_key_id=api_key_id) + + +async def get_or_create_api_key( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + display_name: str, + expiration: dt.timedelta | None = None, +) -> ApiKey: + + key, secret = _generate_api_key_and_secret(display_name) + + api_key: ApiKey = await _repository.get_or_create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=expiration, + api_key=key, + api_secret=secret, + ) + + return api_key + + +async def delete_api_key( + app: web.Application, + *, + api_key_id: str, + user_id: UserID, + product_name: ProductName, +) -> None: + await _repository.delete_api_key( + app, + api_key_id=api_key_id, + user_id=user_id, + product_name=product_name, + ) + + +async def prune_expired_api_keys(app: web.Application) -> list[str]: + names: list[str] = await _repository.prune_expired(app) + return names diff --git a/services/web/server/src/simcore_service_webserver/api_keys/api.py b/services/web/server/src/simcore_service_webserver/api_keys/api.py index df9cb9c8498..9c6a11d719d 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/api.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/api.py @@ -1,4 +1,4 @@ -from ._api import prune_expired_api_keys +from ._service import prune_expired_api_keys __all__: tuple[str, ...] = ("prune_expired_api_keys",) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/errors.py b/services/web/server/src/simcore_service_webserver/api_keys/errors.py new file mode 100644 index 00000000000..5fbe6c38bd9 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/errors.py @@ -0,0 +1,13 @@ +from ..errors import WebServerBaseError + + +class ApiKeysValueError(WebServerBaseError, ValueError): + ... + + +class ApiKeyDuplicatedDisplayNameError(ApiKeysValueError): + msg_template = "API Key with display name '{display_name}' already exists. {reason}" + + +class ApiKeyNotFoundError(ApiKeysValueError): + msg_template = "API Key with ID '{api_key_id}' not found. {reason}" diff --git a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py index b2871596094..9c8cc742c23 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py @@ -8,7 +8,7 @@ from ..products.plugin import setup_products from ..rabbitmq import setup_rabbitmq from ..rest.plugin import setup_rest -from . import _handlers, _rpc +from . import _rest, _rpc _logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def setup_api_keys(app: web.Application): # http api setup_rest(app) - app.router.add_routes(_handlers.routes) + app.router.add_routes(_rest.routes) # rpc api setup_rabbitmq(app) diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py index aa9e1a14065..85f63c42b96 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py +++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py @@ -7,19 +7,21 @@ from collections.abc import AsyncIterable from datetime import timedelta from http import HTTPStatus +from http.client import HTTPException import pytest from aiohttp.test_utils import TestClient +from faker import Faker from models_library.products import ProductName from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from servicelib.aiohttp import status -from simcore_service_webserver.api_keys._api import ( - get_api_key, +from simcore_service_webserver.api_keys import _repository as repo +from simcore_service_webserver.api_keys._models import ApiKey +from simcore_service_webserver.api_keys._service import ( get_or_create_api_key, prune_expired_api_keys, ) -from simcore_service_webserver.api_keys._db import ApiKeyRepo from simcore_service_webserver.db.models import UserRole @@ -28,26 +30,28 @@ async def fake_user_api_keys( client: TestClient, logged_user: UserInfoDict, osparc_product_name: ProductName, -) -> AsyncIterable[list[str]]: + faker: Faker, +) -> AsyncIterable[list[int]]: assert client.app - names = ["foo", "bar", "beta", "alpha"] - repo = ApiKeyRepo.create_from_app(app=client.app) - - for name in names: - await repo.create( + api_keys: list[ApiKey] = [ + await repo.create_api_key( + client.app, user_id=logged_user["id"], product_name=osparc_product_name, - display_name=name, + display_name=faker.pystr(), expiration=None, - api_key=f"{name}-key", - api_secret=f"{name}-secret", + api_key=faker.pystr(), + api_secret=faker.pystr(), ) + for _ in range(5) + ] - yield names + yield api_keys - for name in names: - await repo.delete_by_name( - display_name=name, + for api_key in api_keys: + await repo.delete_api_key( + client.app, + api_key_id=api_key.id, user_id=logged_user["id"], product_name=osparc_product_name, ) @@ -87,7 +91,7 @@ async def test_list_api_keys( "user_role,expected", _get_user_access_parametrizations(status.HTTP_200_OK), ) -async def test_create_api_keys( +async def test_create_api_key( client: TestClient, logged_user: UserInfoDict, user_role: UserRole, @@ -95,18 +99,18 @@ async def test_create_api_keys( disable_gc_manual_guest_users: None, ): display_name = "foo" - resp = await client.post("/v0/auth/api-keys", json={"display_name": display_name}) + resp = await client.post("/v0/auth/api-keys", json={"displayName": display_name}) data, errors = await assert_status(resp, expected) if not errors: - assert data["display_name"] == display_name - assert "api_key" in data - assert "api_secret" in data + assert data["displayName"] == display_name + assert "apiKey" in data + assert "apiSecret" in data resp = await client.get("/v0/auth/api-keys") data, _ = await assert_status(resp, expected) - assert sorted(data) == [display_name] + assert [d["displayName"] for d in data] == [display_name] @pytest.mark.parametrize( @@ -115,17 +119,17 @@ async def test_create_api_keys( ) async def test_delete_api_keys( client: TestClient, - fake_user_api_keys: list[str], + fake_user_api_keys: list[ApiKey], logged_user: UserInfoDict, user_role: UserRole, expected: HTTPStatus, disable_gc_manual_guest_users: None, ): - resp = await client.delete("/v0/auth/api-keys", json={"display_name": "foo"}) + resp = await client.delete("/v0/auth/api-keys/0") await assert_status(resp, expected) - for name in fake_user_api_keys: - resp = await client.delete("/v0/auth/api-keys", json={"display_name": name}) + for api_key in fake_user_api_keys: + resp = await client.delete(f"/v0/auth/api-keys/{api_key.id}") await assert_status(resp, expected) @@ -146,19 +150,19 @@ async def test_create_api_key_with_expiration( expiration_interval = timedelta(seconds=1) resp = await client.post( "/v0/auth/api-keys", - json={"display_name": "foo", "expiration": expiration_interval.seconds}, + json={"displayName": "foo", "expiration": expiration_interval.seconds}, ) data, errors = await assert_status(resp, expected) if not errors: - assert data["display_name"] == "foo" - assert "api_key" in data - assert "api_secret" in data + assert data["displayName"] == "foo" + assert "apiKey" in data + assert "apiSecret" in data # list created api-key resp = await client.get("/v0/auth/api-keys") data, _ = await assert_status(resp, expected) - assert data == ["foo"] + assert [d["displayName"] for d in data] == ["foo"] # wait for api-key for it to expire and force-run scheduled task await asyncio.sleep(expiration_interval.seconds) @@ -180,19 +184,34 @@ async def test_get_or_create_api_key( assert client.app options = { - "name": "repeated_name", "user_id": user["id"], "product_name": "osparc", + "display_name": "foo", } - # does not exist - assert await get_api_key(client.app, **options) is None - # create once created = await get_or_create_api_key(client.app, **options) - assert created.display_name == options["name"] + assert created.display_name == "foo" assert created.api_key != created.api_secret - # idempottent + # idempotent for _ in range(3): assert await get_or_create_api_key(client.app, **options) == created + + +@pytest.mark.parametrize( + "user_role,expected", + _get_user_access_parametrizations(status.HTTP_404_NOT_FOUND), +) +async def test_get_not_existing_api_key( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: HTTPException, + disable_gc_manual_guest_users: None, +): + resp = await client.get("/v0/auth/api-keys/42") + data, errors = await assert_status(resp, expected) + + if not errors: + assert data is None diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py index 51467f3c822..aa45fd9fd2e 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py +++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py @@ -8,19 +8,23 @@ import pytest from aiohttp.test_utils import TestServer from faker import Faker -from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE -from models_library.api_schemas_webserver.auth import ApiKeyCreate from models_library.products import ProductName -from models_library.rabbitmq_basic_types import RPCMethodName -from pydantic import TypeAdapter +from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.webserver.auth.api_keys import ( + create_api_key, + delete_api_key, + get_api_key, +) from settings_library.rabbit import RabbitSettings from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver.api_keys._db import ApiKeyRepo +from simcore_service_webserver.api_keys import _repository as repo +from simcore_service_webserver.api_keys._models import ApiKey +from simcore_service_webserver.api_keys.errors import ApiKeyNotFoundError from simcore_service_webserver.application_settings import ApplicationSettings pytest_simcore_core_services_selection = [ @@ -63,25 +67,29 @@ async def fake_user_api_keys( web_server: TestServer, logged_user: UserInfoDict, osparc_product_name: ProductName, -) -> AsyncIterable[list[str]]: - names = ["foo", "bar", "beta", "alpha"] - repo = ApiKeyRepo.create_from_app(app=web_server.app) + faker: Faker, +) -> AsyncIterable[list[ApiKey]]: + assert web_server.app - for name in names: - await repo.create( + api_keys: list[ApiKey] = [ + await repo.create_api_key( + web_server.app, user_id=logged_user["id"], product_name=osparc_product_name, - display_name=name, + display_name=faker.pystr(), expiration=None, - api_key=f"{name}-key", - api_secret=f"{name}-secret", + api_key=faker.pystr(), + api_secret=faker.pystr(), ) + for _ in range(5) + ] - yield names + yield api_keys - for name in names: - await repo.delete_by_name( - display_name=name, + for api_key in api_keys: + await repo.delete_api_key( + web_server.app, + api_key_id=api_key.id, user_id=logged_user["id"], product_name=osparc_product_name, ) @@ -95,21 +103,20 @@ async def rpc_client( return await rabbitmq_rpc_client("client") -async def test_api_key_get( - fake_user_api_keys: list[str], +async def test_get_api_key( + fake_user_api_keys: list[ApiKey], rpc_client: RabbitMQRPCClient, osparc_product_name: ProductName, logged_user: UserInfoDict, ): - for api_key_name in fake_user_api_keys: - result = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("api_key_get"), - product_name=osparc_product_name, + for api_key in fake_user_api_keys: + result = await get_api_key( + rpc_client, user_id=logged_user["id"], - name=api_key_name, + product_name=osparc_product_name, + api_key_id=api_key.id, ) - assert result.display_name == api_key_name + assert result.id == api_key.id async def test_api_keys_workflow( @@ -122,43 +129,39 @@ async def test_api_keys_workflow( key_name = faker.pystr() # creating a key - created_api_key = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("create_api_keys"), - product_name=osparc_product_name, + created_api_key = await create_api_key( + rpc_client, user_id=logged_user["id"], - new=ApiKeyCreate(display_name=key_name, expiration=None), + product_name=osparc_product_name, + api_key=ApiKeyCreate(display_name=key_name, expiration=None), ) assert created_api_key.display_name == key_name # query the key is still present - queried_api_key = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("api_key_get"), + queried_api_key = await get_api_key( + rpc_client, product_name=osparc_product_name, user_id=logged_user["id"], - name=key_name, + api_key_id=created_api_key.id, ) assert queried_api_key.display_name == key_name - assert created_api_key == queried_api_key + assert created_api_key.id == queried_api_key.id + assert created_api_key.display_name == queried_api_key.display_name # remove the key - delete_key_result = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("delete_api_keys"), - product_name=osparc_product_name, + await delete_api_key( + rpc_client, user_id=logged_user["id"], - name=key_name, - ) - assert delete_key_result is None - - # key no longer present - query_missing_query = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("api_key_get"), product_name=osparc_product_name, - user_id=logged_user["id"], - name=key_name, + api_key_id=created_api_key.id, ) - assert query_missing_query is None + + with pytest.raises(ApiKeyNotFoundError): + # key no longer present + await get_api_key( + rpc_client, + product_name=osparc_product_name, + user_id=logged_user["id"], + api_key_id=created_api_key.id, + ) diff --git a/tests/public-api/conftest.py b/tests/public-api/conftest.py index 3b4a0b27b9c..b8f40710e08 100644 --- a/tests/public-api/conftest.py +++ b/tests/public-api/conftest.py @@ -147,25 +147,23 @@ def registered_user( resp.raise_for_status() # create a key via web-api - resp = client.post("/auth/api-keys", json={"display_name": "test-public-api"}) + resp = client.post("/auth/api-keys", json={"displayName": "test-public-api"}) print(resp.text) resp.raise_for_status() data = resp.json()["data"] - assert data["display_name"] == "test-public-api" + assert data["displayName"] == "test-public-api" - assert "api_key" in data - assert "api_secret" in data + assert "apiKey" in data + assert "apiSecret" in data - user["api_key"] = data["api_key"] - user["api_secret"] = data["api_secret"] + user["api_key"] = data["apiKey"] + user["api_secret"] = data["apiSecret"] yield user - resp = client.request( - "DELETE", "/auth/api-keys", json={"display_name": "test-public-api"} - ) + resp = client.delete(f"/auth/api-keys/{data['id']}") @pytest.fixture(scope="module") @@ -283,7 +281,7 @@ def api_client( def as_dict(obj: object): return { attr: getattr(obj, attr) - for attr in obj.__dict__.keys() + for attr in obj.__dict__ if not attr.startswith("_") } From 81ce420738cd99aef6718b86813834cb95067085 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard <126242332+bisgaard-itis@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:35:48 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20upgrade=20api-server?= =?UTF-8?q?=20dependencies=20(#6860)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-testing-pull-request.yml | 14 +- services/api-server/Makefile | 1 + services/api-server/openapi.json | 2 + services/api-server/requirements/_base.txt | 212 ++++++++++-------- services/api-server/requirements/_test.txt | 107 +++++---- services/api-server/requirements/_tools.txt | 41 ++-- .../api-server/requirements/constraints.txt | 3 + .../api/v0/openapi.yaml | 36 ++- 8 files changed, 237 insertions(+), 179 deletions(-) diff --git a/.github/workflows/ci-testing-pull-request.yml b/.github/workflows/ci-testing-pull-request.yml index 3d877cb091a..a8c3641b4df 100644 --- a/.github/workflows/ci-testing-pull-request.yml +++ b/.github/workflows/ci-testing-pull-request.yml @@ -17,7 +17,7 @@ concurrency: jobs: api-specs: timeout-minutes: 10 - name: "check oas' are up to date" + name: "check OAS' are up to date" runs-on: ubuntu-latest steps: - name: setup python environment @@ -35,11 +35,13 @@ jobs: run: | uv venv .venv && source .venv/bin/activate make openapi-specs - ./ci/github/helpers/openapi-specs-diff.bash diff \ + if ! ./ci/github/helpers/openapi-specs-diff.bash diff \ https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name }}/refs/heads/${{ github.event.pull_request.head.ref }} \ - . + .; then \ + echo "::error:: OAS are not up to date. Run 'make openapi-specs' to update them"; exit 1; \ + fi - api-server-backwards-compatibility: + api-server-oas-breaking: needs: api-specs timeout-minutes: 10 name: "api-server backwards compatibility" @@ -62,11 +64,11 @@ jobs: https://raw.githubusercontent.com/${{ github.event.pull_request.base.repo.full_name }}/refs/heads/${{ github.event.pull_request.base.ref }}/services/api-server/openapi.json \ /specs/services/api-server/openapi.json - oas-backwards-compatibility: + all-oas-breaking: needs: api-specs continue-on-error: true timeout-minutes: 10 - name: "oas backwards compatibility" + name: "OAS backwards compatibility" runs-on: ubuntu-latest steps: - name: setup python environment diff --git a/services/api-server/Makefile b/services/api-server/Makefile index 39672c9764e..82263c83658 100644 --- a/services/api-server/Makefile +++ b/services/api-server/Makefile @@ -87,6 +87,7 @@ openapi-diff.md: guard-OPENAPI_JSON_BASE_URL openapi.json ## Diffs against a rem # SEE https://schemathesis.readthedocs.io/en/stable/index.html APP_URL:=http://$(get_my_ip).nip.io:8006 + test-api: ## Runs schemathesis against development server (NOTE: make up-devel first) @docker run schemathesis/schemathesis:stable run \ "$(APP_URL)/api/v0/openapi.json" diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index ee8d7f9479d..d70ef50e6bc 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -5548,6 +5548,7 @@ "urls": { "items": { "type": "string", + "minLength": 1, "format": "uri" }, "type": "array", @@ -6161,6 +6162,7 @@ }, "download_link": { "type": "string", + "minLength": 1, "format": "uri", "title": "Download Link" } diff --git a/services/api-server/requirements/_base.txt b/services/api-server/requirements/_base.txt index 48d12d8b832..a49ef21c613 100644 --- a/services/api-server/requirements/_base.txt +++ b/services/api-server/requirements/_base.txt @@ -1,8 +1,8 @@ -aio-pika==9.4.1 +aio-pika==9.5.3 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -aiocache==0.12.2 +aiocache==0.12.3 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in @@ -11,17 +11,19 @@ aiodebug==2.3.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -aiodocker==0.21.0 +aiodocker==0.24.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -aiofiles==23.2.1 +aiofiles==24.1.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/_base.in # -r requirements/_base.in -aiohttp==3.9.3 +aiohappyeyeballs==2.4.4 + # via aiohttp +aiohttp==3.11.10 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -57,25 +59,23 @@ aiopg==1.4.0 # via # -r requirements/../../../packages/simcore-sdk/requirements/_base.in # -r requirements/_base.in -aiormq==6.8.0 +aiormq==6.8.1 # via aio-pika aiosignal==1.3.1 # via aiohttp -alembic==1.13.1 +alembic==1.14.0 # via # -r requirements/../../../packages/postgres-database/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in annotated-types==0.7.0 # via pydantic -anyio==4.3.0 +anyio==4.7.0 # via # fast-depends # faststream # httpx # starlette # watchfiles -appdirs==1.4.4 - # via pint arrow==1.3.0 # via # -r requirements/../../../packages/models-library/requirements/_base.in @@ -87,16 +87,14 @@ arrow==1.3.0 asgiref==3.8.1 # via opentelemetry-instrumentation-asgi async-timeout==4.0.3 - # via - # aiopg - # asyncpg -asyncpg==0.29.0 + # via aiopg +asyncpg==0.30.0 # via sqlalchemy -attrs==23.2.0 +attrs==24.2.0 # via # aiohttp # jsonschema -certifi==2024.2.2 +certifi==2024.8.30 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -129,15 +127,16 @@ certifi==2024.2.2 # httpcore # httpx # requests -cffi==1.16.0 +cffi==1.17.1 # via cryptography -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via requests click==8.1.7 # via + # rich-toolkit # typer # uvicorn -cryptography==42.0.5 +cryptography==44.0.0 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -168,58 +167,59 @@ cryptography==42.0.5 # -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # -r requirements/_base.in -deprecated==1.2.14 +deprecated==1.2.15 # via # opentelemetry-api # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http # opentelemetry-semantic-conventions -dnspython==2.6.1 +dnspython==2.7.0 # via email-validator -email-validator==2.1.1 +email-validator==2.2.0 # via # fastapi # pydantic -fast-depends==2.4.2 +exceptiongroup==1.2.2 + # via aio-pika +fast-depends==2.4.12 # via faststream -fastapi==0.115.5 +fastapi==0.115.6 # via # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in - # prometheus-fastapi-instrumentator -fastapi-cli==0.0.5 +fastapi-cli==0.0.6 # via fastapi -fastapi-pagination==0.12.31 +fastapi-pagination==0.12.32 # via -r requirements/_base.in -faststream==0.5.31 +faststream==0.5.33 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in flexcache==0.3 # via pint -flexparser==0.3.1 +flexparser==0.4 # via pint -frozenlist==1.4.1 +frozenlist==1.5.0 # via # aiohttp # aiosignal -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.66.0 # via # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http -greenlet==3.0.3 +greenlet==3.1.1 # via sqlalchemy -grpcio==1.66.0 +grpcio==1.68.1 # via opentelemetry-exporter-otlp-proto-grpc h11==0.14.0 # via # httpcore # uvicorn -httpcore==1.0.5 +httpcore==1.0.7 # via httpx -httptools==0.6.1 +httptools==0.6.4 # via uvicorn -httpx==0.27.0 +httpx==0.27.2 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -249,21 +249,22 @@ httpx==0.27.0 # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt + # -c requirements/./constraints.txt # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in # fastapi -idna==3.6 +idna==3.10 # via # anyio # email-validator # httpx # requests # yarl -importlib-metadata==8.0.0 +importlib-metadata==8.5.0 # via opentelemetry-api -itsdangerous==2.1.2 +itsdangerous==2.2.0 # via fastapi -jinja2==3.1.3 +jinja2==3.1.4 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -301,7 +302,7 @@ jsonschema==3.2.0 # -r requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in -mako==1.3.2 +mako==1.3.7 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -334,17 +335,17 @@ mako==1.3.2 # alembic markdown-it-py==3.0.0 # via rich -markupsafe==2.1.5 +markupsafe==3.0.2 # via # jinja2 # mako mdurl==0.1.2 # via markdown-it-py -multidict==6.0.5 +multidict==6.1.0 # via # aiohttp # yarl -opentelemetry-api==1.27.0 +opentelemetry-api==1.28.2 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in @@ -362,19 +363,19 @@ opentelemetry-api==1.27.0 # opentelemetry-instrumentation-requests # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp==1.27.0 +opentelemetry-exporter-otlp==1.28.2 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -opentelemetry-exporter-otlp-proto-common==1.27.0 +opentelemetry-exporter-otlp-proto-common==1.28.2 # via # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-grpc==1.27.0 +opentelemetry-exporter-otlp-proto-grpc==1.28.2 # via opentelemetry-exporter-otlp -opentelemetry-exporter-otlp-proto-http==1.27.0 +opentelemetry-exporter-otlp-proto-http==1.28.2 # via opentelemetry-exporter-otlp -opentelemetry-instrumentation==0.48b0 +opentelemetry-instrumentation==0.49b2 # via # opentelemetry-instrumentation-aiopg # opentelemetry-instrumentation-asgi @@ -385,45 +386,46 @@ opentelemetry-instrumentation==0.48b0 # opentelemetry-instrumentation-logging # opentelemetry-instrumentation-redis # opentelemetry-instrumentation-requests -opentelemetry-instrumentation-aiopg==0.48b0 +opentelemetry-instrumentation-aiopg==0.49b2 # via -r requirements/../../../packages/simcore-sdk/requirements/_base.in -opentelemetry-instrumentation-asgi==0.48b0 +opentelemetry-instrumentation-asgi==0.49b2 # via opentelemetry-instrumentation-fastapi -opentelemetry-instrumentation-asyncpg==0.48b0 +opentelemetry-instrumentation-asyncpg==0.49b2 # via # -r requirements/../../../packages/postgres-database/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in -opentelemetry-instrumentation-dbapi==0.48b0 +opentelemetry-instrumentation-dbapi==0.49b2 # via opentelemetry-instrumentation-aiopg -opentelemetry-instrumentation-fastapi==0.48b0 +opentelemetry-instrumentation-fastapi==0.49b2 # via -r requirements/../../../packages/service-library/requirements/_fastapi.in -opentelemetry-instrumentation-httpx==0.48b0 +opentelemetry-instrumentation-httpx==0.49b2 # via -r requirements/../../../packages/service-library/requirements/_fastapi.in -opentelemetry-instrumentation-logging==0.48b0 +opentelemetry-instrumentation-logging==0.49b2 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -opentelemetry-instrumentation-redis==0.48b0 +opentelemetry-instrumentation-redis==0.49b2 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -opentelemetry-instrumentation-requests==0.48b0 +opentelemetry-instrumentation-requests==0.49b2 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -opentelemetry-proto==1.27.0 +opentelemetry-proto==1.28.2 # via # opentelemetry-exporter-otlp-proto-common # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.27.0 +opentelemetry-sdk==1.28.2 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http -opentelemetry-semantic-conventions==0.48b0 +opentelemetry-semantic-conventions==0.49b2 # via + # opentelemetry-instrumentation # opentelemetry-instrumentation-asgi # opentelemetry-instrumentation-asyncpg # opentelemetry-instrumentation-dbapi @@ -432,13 +434,13 @@ opentelemetry-semantic-conventions==0.48b0 # opentelemetry-instrumentation-redis # opentelemetry-instrumentation-requests # opentelemetry-sdk -opentelemetry-util-http==0.48b0 +opentelemetry-util-http==0.49b2 # via # opentelemetry-instrumentation-asgi # opentelemetry-instrumentation-fastapi # opentelemetry-instrumentation-httpx # opentelemetry-instrumentation-requests -orjson==3.10.0 +orjson==3.10.12 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -488,37 +490,44 @@ orjson==3.10.0 # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/_base.in # -r requirements/_base.in # fastapi -packaging==24.0 +packaging==24.2 # via # -r requirements/../../../packages/simcore-sdk/requirements/_base.in # -r requirements/_base.in + # opentelemetry-instrumentation pamqp==3.3.0 # via aiormq parse==1.20.2 # via -r requirements/_base.in -pint==0.24.3 +pint==0.24.4 # via -r requirements/../../../packages/simcore-sdk/requirements/_base.in -prometheus-client==0.20.0 +platformdirs==4.3.6 + # via pint +prometheus-client==0.21.1 # via # -r requirements/../../../packages/service-library/requirements/_fastapi.in # prometheus-fastapi-instrumentator -prometheus-fastapi-instrumentator==6.1.0 +prometheus-fastapi-instrumentator==7.0.0 # via -r requirements/../../../packages/service-library/requirements/_fastapi.in -protobuf==4.25.4 +propcache==0.2.1 + # via + # aiohttp + # yarl +protobuf==5.29.1 # via # googleapis-common-protos # opentelemetry-proto -psutil==6.0.0 +psutil==6.1.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -psycopg2-binary==2.9.9 +psycopg2-binary==2.9.10 # via # aiopg # sqlalchemy pycparser==2.22 # via cffi -pydantic==2.10.2 +pydantic==2.10.3 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -583,7 +592,7 @@ pydantic==2.10.2 # pydantic-settings pydantic-core==2.27.1 # via pydantic -pydantic-extra-types==2.9.0 +pydantic-extra-types==2.10.0 # via # -r requirements/../../../packages/common-library/requirements/_base.in # -r requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/_base.in @@ -615,9 +624,9 @@ pydantic-settings==2.6.1 # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in # fastapi -pygments==2.17.2 +pygments==2.18.0 # via rich -pyinstrument==4.6.2 +pyinstrument==5.0.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in @@ -629,9 +638,9 @@ python-dotenv==1.0.1 # via # pydantic-settings # uvicorn -python-multipart==0.0.9 +python-multipart==0.0.19 # via fastapi -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -666,7 +675,7 @@ pyyaml==6.0.1 # -r requirements/_base.in # fastapi # uvicorn -redis==5.0.4 +redis==5.2.0 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -704,20 +713,21 @@ repro-zipfile==0.3.1 # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in requests==2.32.3 # via opentelemetry-exporter-otlp-proto-http -rich==13.7.1 +rich==13.9.4 # via # -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in + # rich-toolkit # typer -setuptools==69.2.0 - # via - # jsonschema - # opentelemetry-instrumentation +rich-toolkit==0.12.0 + # via fastapi-cli +setuptools==75.6.0 + # via jsonschema shellingham==1.5.4 # via typer -six==1.16.0 +six==1.17.0 # via # jsonschema # python-dateutil @@ -725,7 +735,7 @@ sniffio==1.3.1 # via # anyio # httpx -sqlalchemy==1.4.52 +sqlalchemy==1.4.54 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -759,7 +769,7 @@ sqlalchemy==1.4.52 # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in # aiopg # alembic -starlette==0.41.2 +starlette==0.41.3 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -790,22 +800,23 @@ starlette==0.41.2 # -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # fastapi -tenacity==8.5.0 + # prometheus-fastapi-instrumentator +tenacity==9.0.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/_base.in # -r requirements/_base.in -toolz==0.12.1 +toolz==1.0.0 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in -tqdm==4.66.2 +tqdm==4.67.1 # via # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/simcore-sdk/requirements/_base.in -typer==0.12.3 +typer==0.15.1 # via # -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/../../../packages/settings-library/requirements/_base.in @@ -813,13 +824,13 @@ typer==0.12.3 # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in # -r requirements/_base.in # fastapi-cli -types-python-dateutil==2.9.0.20240316 +types-python-dateutil==2.9.0.20241206 # via arrow typing-extensions==4.12.2 # via # aiodebug - # aiodocker # alembic + # anyio # fastapi # fastapi-pagination # faststream @@ -829,8 +840,10 @@ typing-extensions==4.12.2 # pint # pydantic # pydantic-core + # pydantic-extra-types + # rich-toolkit # typer -ujson==5.9.0 +ujson==5.10.0 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt @@ -892,25 +905,26 @@ urllib3==2.2.3 # -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # requests -uvicorn==0.29.0 +uvicorn==0.32.1 # via # -r requirements/../../../packages/service-library/requirements/_fastapi.in # fastapi # fastapi-cli -uvloop==0.19.0 +uvloop==0.21.0 # via uvicorn -watchfiles==0.21.0 +watchfiles==1.0.0 # via uvicorn -websockets==12.0 +websockets==14.1 # via uvicorn -wrapt==1.16.0 +wrapt==1.17.0 # via # deprecated # opentelemetry-instrumentation # opentelemetry-instrumentation-aiopg # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-httpx # opentelemetry-instrumentation-redis -yarl==1.9.4 +yarl==1.18.3 # via # -r requirements/../../../packages/postgres-database/requirements/_base.in # -r requirements/../../../packages/service-library/requirements/_base.in @@ -919,5 +933,5 @@ yarl==1.9.4 # aio-pika # aiohttp # aiormq -zipp==3.20.1 +zipp==3.21.0 # via importlib-metadata diff --git a/services/api-server/requirements/_test.txt b/services/api-server/requirements/_test.txt index e5254193173..a9341530919 100644 --- a/services/api-server/requirements/_test.txt +++ b/services/api-server/requirements/_test.txt @@ -1,25 +1,29 @@ -aiohttp==3.9.3 +aiohappyeyeballs==2.4.4 + # via + # -c requirements/_base.txt + # aiohttp +aiohttp==3.11.10 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt # aioresponses -aioresponses==0.7.6 +aioresponses==0.7.7 # via -r requirements/_test.in aiosignal==1.3.1 # via # -c requirements/_base.txt # aiohttp -alembic==1.13.1 +alembic==1.14.0 # via # -c requirements/_base.txt # -r requirements/_test.in -anyio==4.3.0 +anyio==4.7.0 # via # -c requirements/_base.txt # httpx asgi-lifespan==2.1.0 # via -r requirements/_test.in -attrs==23.2.0 +attrs==24.2.0 # via # -c requirements/_base.txt # aiohttp @@ -33,28 +37,26 @@ aws-sam-translator==1.55.0 # cfn-lint aws-xray-sdk==2.14.0 # via moto -boto3==1.35.25 +boto3==1.35.76 # via # aws-sam-translator # moto -boto3-stubs==1.35.25 - # via types-boto3 -botocore==1.35.25 +botocore==1.35.76 # via # aws-xray-sdk # boto3 # moto # s3transfer -botocore-stubs==1.35.25 - # via boto3-stubs -certifi==2024.2.2 +botocore-stubs==1.35.76 + # via types-boto3 +certifi==2024.8.30 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt # httpcore # httpx # requests -cffi==1.16.0 +cffi==1.17.1 # via # -c requirements/_base.txt # cryptography @@ -62,7 +64,7 @@ cfn-lint==0.72.0 # via # -c requirements/./constraints.txt # moto -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via # -c requirements/_base.txt # requests @@ -71,9 +73,9 @@ click==8.1.7 # -c requirements/_base.txt # -r requirements/_test.in # flask -coverage==7.6.1 +coverage==7.6.8 # via pytest-cov -cryptography==42.0.5 +cryptography==44.0.0 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt @@ -89,7 +91,7 @@ ecdsa==0.19.0 # moto # python-jose # sshpubkeys -faker==29.0.0 +faker==33.1.0 # via -r requirements/_test.in flask==2.1.3 # via @@ -97,14 +99,14 @@ flask==2.1.3 # moto flask-cors==5.0.0 # via moto -frozenlist==1.4.1 +frozenlist==1.5.0 # via # -c requirements/_base.txt # aiohttp # aiosignal -graphql-core==3.2.4 +graphql-core==3.2.5 # via moto -greenlet==3.0.3 +greenlet==3.1.1 # via # -c requirements/_base.txt # sqlalchemy @@ -112,16 +114,17 @@ h11==0.14.0 # via # -c requirements/_base.txt # httpcore -httpcore==1.0.5 +httpcore==1.0.7 # via # -c requirements/_base.txt # httpx -httpx==0.27.0 +httpx==0.27.2 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt + # -c requirements/./constraints.txt # respx -idna==3.6 +idna==3.10 # via # -c requirements/_base.txt # anyio @@ -131,11 +134,11 @@ idna==3.6 # yarl iniconfig==2.0.0 # via pytest -itsdangerous==2.1.2 +itsdangerous==2.2.0 # via # -c requirements/_base.txt # flask -jinja2==3.1.3 +jinja2==3.1.4 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt @@ -151,7 +154,7 @@ jsondiff==2.2.1 # via moto jsonpatch==1.33 # via cfn-lint -jsonpickle==3.3.0 +jsonpickle==4.0.0 # via jschema-to-python jsonpointer==3.0.0 # via jsonpatch @@ -167,12 +170,12 @@ jsonschema==3.2.0 # openapi-spec-validator junit-xml==1.9 # via cfn-lint -mako==1.3.2 +mako==1.3.7 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt # alembic -markupsafe==2.1.5 +markupsafe==3.0.2 # via # -c requirements/_base.txt # jinja2 @@ -182,12 +185,12 @@ moto==4.0.1 # via # -c requirements/./constraints.txt # -r requirements/_test.in -multidict==6.0.5 +multidict==6.1.0 # via # -c requirements/_base.txt # aiohttp # yarl -mypy==1.12.0 +mypy==1.13.0 # via sqlalchemy mypy-extensions==1.0.0 # via mypy @@ -199,9 +202,10 @@ openapi-spec-validator==0.4.0 # via # -c requirements/./constraints.txt # moto -packaging==24.0 +packaging==24.2 # via # -c requirements/_base.txt + # aioresponses # pytest pbr==6.1.0 # via @@ -209,6 +213,11 @@ pbr==6.1.0 # sarif-om pluggy==1.5.0 # via pytest +propcache==0.2.1 + # via + # -c requirements/_base.txt + # aiohttp + # yarl pyasn1==0.6.1 # via # python-jose @@ -217,17 +226,17 @@ pycparser==2.22 # via # -c requirements/_base.txt # cffi -pyinstrument==4.6.2 +pyinstrument==5.0.0 # via # -c requirements/_base.txt # -r requirements/_test.in -pyparsing==3.1.4 +pyparsing==3.2.0 # via moto pyrsistent==0.20.0 # via # -c requirements/_base.txt # jsonschema -pytest==8.3.3 +pytest==8.3.4 # via # -r requirements/_test.in # pytest-asyncio @@ -238,7 +247,7 @@ pytest-asyncio==0.23.8 # via # -c requirements/../../../requirements/constraints.txt # -r requirements/_test.in -pytest-cov==5.0.0 +pytest-cov==6.0.0 # via -r requirements/_test.in pytest-docker==3.1.1 # via -r requirements/_test.in @@ -256,7 +265,7 @@ python-jose==3.3.0 # via moto pytz==2024.2 # via moto -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt @@ -279,17 +288,17 @@ rsa==4.9 # via # -c requirements/../../../requirements/constraints.txt # python-jose -s3transfer==0.10.2 +s3transfer==0.10.4 # via boto3 sarif-om==1.0.4 # via cfn-lint -setuptools==69.2.0 +setuptools==75.6.0 # via # -c requirements/_base.txt # jsonschema # moto # openapi-spec-validator -six==1.16.0 +six==1.17.0 # via # -c requirements/_base.txt # ecdsa @@ -302,7 +311,7 @@ sniffio==1.3.1 # anyio # asgi-lifespan # httpx -sqlalchemy==1.4.52 +sqlalchemy==1.4.54 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt @@ -314,19 +323,21 @@ sshpubkeys==3.3.1 # via moto types-aiofiles==24.1.0.20240626 # via -r requirements/_test.in -types-awscrt==0.21.5 +types-awscrt==0.23.3 # via botocore-stubs -types-boto3==1.0.2 +types-boto3==1.35.76 # via -r requirements/_test.in -types-s3transfer==0.10.2 - # via boto3-stubs +types-s3transfer==0.10.4 + # via types-boto3 typing-extensions==4.12.2 # via # -c requirements/_base.txt # alembic - # boto3-stubs + # anyio + # faker # mypy # sqlalchemy2-stubs + # types-boto3 urllib3==2.2.3 # via # -c requirements/../../../requirements/constraints.txt @@ -339,13 +350,13 @@ werkzeug==2.1.2 # via # flask # moto -wrapt==1.16.0 +wrapt==1.17.0 # via # -c requirements/_base.txt # aws-xray-sdk -xmltodict==0.13.0 +xmltodict==0.14.2 # via moto -yarl==1.9.4 +yarl==1.18.3 # via # -c requirements/_base.txt # aiohttp diff --git a/services/api-server/requirements/_tools.txt b/services/api-server/requirements/_tools.txt index 795564deb10..ba68de2e0b8 100644 --- a/services/api-server/requirements/_tools.txt +++ b/services/api-server/requirements/_tools.txt @@ -1,8 +1,8 @@ -astroid==3.3.4 +astroid==3.3.5 # via pylint -black==24.8.0 +black==24.10.0 # via -r requirements/../../../requirements/devenv.txt -build==1.2.2 +build==1.2.2.post1 # via pip-tools bump2version==1.0.1 # via -r requirements/../../../requirements/devenv.txt @@ -16,32 +16,32 @@ click==8.1.7 # -c requirements/_test.txt # black # pip-tools -dill==0.3.8 +dill==0.3.9 # via pylint -distlib==0.3.8 +distlib==0.3.9 # via virtualenv filelock==3.16.1 # via virtualenv -identify==2.6.1 +identify==2.6.3 # via pre-commit isort==5.13.2 # via # -r requirements/../../../requirements/devenv.txt # pylint -jinja2==3.1.3 +jinja2==3.1.4 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt # -c requirements/_test.txt # -r requirements/_tools.in -markupsafe==2.1.5 +markupsafe==3.0.2 # via # -c requirements/_base.txt # -c requirements/_test.txt # jinja2 mccabe==0.7.0 # via pylint -mypy==1.12.0 +mypy==1.13.0 # via # -c requirements/_test.txt # -r requirements/../../../requirements/devenv.txt @@ -52,7 +52,7 @@ mypy-extensions==1.0.0 # mypy nodeenv==1.9.1 # via pre-commit -packaging==24.0 +packaging==24.2 # via # -c requirements/_base.txt # -c requirements/_test.txt @@ -60,33 +60,34 @@ packaging==24.0 # build pathspec==0.12.1 # via black -pip==24.2 +pip==24.3.1 # via pip-tools pip-tools==7.4.1 # via -r requirements/../../../requirements/devenv.txt platformdirs==4.3.6 # via + # -c requirements/_base.txt # black # pylint # virtualenv -pre-commit==3.8.0 +pre-commit==4.0.1 # via -r requirements/../../../requirements/devenv.txt -pylint==3.3.0 +pylint==3.3.2 # via -r requirements/../../../requirements/devenv.txt -pyproject-hooks==1.1.0 +pyproject-hooks==1.2.0 # via # build # pip-tools -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt # -c requirements/_test.txt # pre-commit # watchdog -ruff==0.6.7 +ruff==0.8.2 # via -r requirements/../../../requirements/devenv.txt -setuptools==69.2.0 +setuptools==75.6.0 # via # -c requirements/_base.txt # -c requirements/_test.txt @@ -98,9 +99,9 @@ typing-extensions==4.12.2 # -c requirements/_base.txt # -c requirements/_test.txt # mypy -virtualenv==20.26.5 +virtualenv==20.28.0 # via pre-commit -watchdog==5.0.2 +watchdog==6.0.0 # via -r requirements/_tools.in -wheel==0.44.0 +wheel==0.45.1 # via pip-tools diff --git a/services/api-server/requirements/constraints.txt b/services/api-server/requirements/constraints.txt index 6247b000156..2991a5be8b4 100644 --- a/services/api-server/requirements/constraints.txt +++ b/services/api-server/requirements/constraints.txt @@ -40,3 +40,6 @@ aws-sam-translator<1.56.0 # # aws-sam-translator<1.55.0 (from -c ./constraints.txt (line 32)) # # aws-sam-translator>=1.57.0 (from cfn-lint==0.72.10->-c ./constraints.txt (line 33)) cfn-lint<0.72.1 + +# due to https://github.com/lundberg/respx/pull/278 +httpx!=0.28.0 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 db975aa23c3..bc73e5441d2 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 @@ -396,6 +396,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_ApiKeyGet__' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' '404': description: Not Found content: @@ -421,6 +427,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' '404': description: Not Found content: @@ -450,6 +462,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ApiKeyGet_' + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict '404': content: application/json: @@ -474,6 +492,12 @@ paths: responses: '204': description: Successful Response + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict '404': content: application/json: @@ -6765,6 +6789,11 @@ components: title: ApiKeyCreateRequest ApiKeyCreateResponse: properties: + id: + type: string + maxLength: 100 + minLength: 1 + title: Id displayName: type: string minLength: 3 @@ -6777,11 +6806,6 @@ components: title: Expiration description: Time delta from creation time to expiration. If None, then it does not expire. - id: - type: string - maxLength: 100 - minLength: 1 - title: Id apiBaseUrl: type: string title: Apibaseurl @@ -6793,8 +6817,8 @@ components: title: Apisecret type: object required: - - displayName - id + - displayName - apiBaseUrl - apiKey - apiSecret